diff --git a/.env.example b/.env.example index fd12851..c8552fc 100644 --- a/.env.example +++ b/.env.example @@ -27,6 +27,11 @@ GOOGLE_CLIENT_ID=change-me.apps.googleusercontent.com GOOGLE_CLIENT_SECRET=change-me APP_OAUTH2_REDIRECT_URI=http://localhost:3000/oauth2/redirect OPENAI_API_KEY=change-me + +TOSS_CLIENT_KEY=change-me +TOSS_SECRET_KEY=change-me +TOSS_BASE_URL=https://api.tosspayments.com + JOB_POSTING_CLASSIFICATION_CONFIDENCE_THRESHOLD=0.65 JOB_POSTING_ASYNC_CORE_POOL_SIZE=2 JOB_POSTING_ASYNC_MAX_POOL_SIZE=4 diff --git a/.env.production.example b/.env.production.example index 9d5c50d..a932e20 100644 --- a/.env.production.example +++ b/.env.production.example @@ -31,6 +31,11 @@ GOOGLE_CLIENT_ID=change-me.apps.googleusercontent.com GOOGLE_CLIENT_SECRET=change-me APP_OAUTH2_REDIRECT_URI=https://your-frontend-domain/oauth2/redirect OPENAI_API_KEY=change-me + +TOSS_CLIENT_KEY=change-me +TOSS_SECRET_KEY=change-me +TOSS_BASE_URL=https://api.tosspayments.com + JOB_POSTING_CLASSIFICATION_CONFIDENCE_THRESHOLD=0.65 JOB_POSTING_ASYNC_CORE_POOL_SIZE=2 JOB_POSTING_ASYNC_MAX_POOL_SIZE=4 diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisService.java index a787cda..85b2ace 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisService.java @@ -14,6 +14,7 @@ import com.jobdri.jobdri_api.domain.mockapply.entity.MockApply; import com.jobdri.jobdri_api.domain.mockapply.entity.MockApplyStatus; import com.jobdri.jobdri_api.domain.mockapply.repository.MockApplyRepository; +import com.jobdri.jobdri_api.domain.payment.service.CreditService; import com.jobdri.jobdri_api.domain.user.entity.User; import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; @@ -40,6 +41,7 @@ public class AnalysisService { private final AnalysisRepository analysisRepository; private final QuestionAnalysisRepository questionAnalysisRepository; private final AnalysisAiClient analysisAiClient; + private final CreditService creditService; @Transactional public AnalysisResponse analyze(User user, Long mockApplyId) { @@ -56,23 +58,31 @@ public AnalysisResponse analyze(User user, Long mockApplyId) { ); } - AnalysisLlmResponse llmResponse = analysisAiClient.analyze(mockApply.getJobPosting(), answeredQuestions); - replaceExistingAnalysis(mockApply); + String referenceId = "mockApplyId=" + mockApply.getId(); + creditService.use(user, 1, "자소서 분석 크레딧 차감", referenceId); - Analysis analysis = analysisRepository.save(Analysis.create( - mockApply, - clampScore(llmResponse.score()), - clampScore(llmResponse.jobFit()), - clampScore(llmResponse.impact()), - clampScore(llmResponse.completeness()), - normalizeFeedback(llmResponse.feedback()) - )); + try { + AnalysisLlmResponse llmResponse = analysisAiClient.analyze(mockApply.getJobPosting(), answeredQuestions); + replaceExistingAnalysis(mockApply); + + Analysis analysis = analysisRepository.save(Analysis.create( + mockApply, + clampScore(llmResponse.score()), + clampScore(llmResponse.jobFit()), + clampScore(llmResponse.impact()), + clampScore(llmResponse.completeness()), + normalizeFeedback(llmResponse.feedback()) + )); - List questionAnalyses = buildQuestionAnalyses(analysis, answeredQuestions, llmResponse); - questionAnalysisRepository.saveAll(questionAnalyses); - mockApply.updateStatus(MockApplyStatus.COMPLETED); + List questionAnalyses = buildQuestionAnalyses(analysis, answeredQuestions, llmResponse); + questionAnalysisRepository.saveAll(questionAnalyses); + mockApply.updateStatus(MockApplyStatus.COMPLETED); - return getAnalysis(user, mockApplyId); + return getAnalysis(user, mockApplyId); + } catch (RuntimeException e) { + creditService.refund(user, 1, "자소서 분석 실패 환불", referenceId); + throw e; + } } public AnalysisResponse getAnalysis(User user, Long mockApplyId) { diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/controller/PaymentController.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/controller/PaymentController.java new file mode 100644 index 0000000..fa4e160 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/controller/PaymentController.java @@ -0,0 +1,79 @@ +package com.jobdri.jobdri_api.domain.payment.controller; + +import com.jobdri.jobdri_api.domain.payment.dto.request.PaymentConfirmRequest; +import com.jobdri.jobdri_api.domain.payment.dto.request.PaymentPrepareRequest; +import com.jobdri.jobdri_api.domain.payment.dto.response.*; +import com.jobdri.jobdri_api.domain.payment.entity.CreditTransactionType; +import com.jobdri.jobdri_api.domain.payment.service.PaymentService; +import com.jobdri.jobdri_api.global.apiPayload.ApiResponse; +import com.jobdri.jobdri_api.global.security.UserDetailsImpl; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/payments") +@Tag(name = "Payment", description = "크레딧 결제 및 거래 내역 API") +public class PaymentController { + + private final PaymentService paymentService; + + @Operation(summary = "크레딧 가격 플랜 조회", description = "구매 가능한 크레딧 플랜 목록을 조회합니다.") + @GetMapping("/plans") + public ApiResponse> getPlans() { + return ApiResponse.onSuccess("크레딧 가격 플랜 조회에 성공했습니다.", paymentService.getPlans()); + } + + @Operation(summary = "토스 결제 준비", description = "선택한 크레딧 플랜 기준으로 결제 주문을 생성합니다.") + @PostMapping("/prepare") + public ApiResponse prepare( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @Valid @RequestBody PaymentPrepareRequest request + ) { + return ApiResponse.onSuccess( + "결제 준비가 완료되었습니다.", + paymentService.prepare(userDetails.getUser(), request) + ); + } + + @Operation(summary = "토스 결제 승인", description = "토스페이먼츠 결제 성공 후 paymentKey/orderId/amount를 검증하고 크레딧을 충전합니다.") + @PostMapping("/confirm") + public ApiResponse confirm( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @Valid @RequestBody PaymentConfirmRequest request + ) { + return ApiResponse.onSuccess( + "결제가 완료되었습니다.", + paymentService.confirm(userDetails.getUser(), request) + ); + } + + @Operation(summary = "내 크레딧 잔액 조회", description = "로그인 사용자의 현재 크레딧 잔액을 조회합니다.") + @GetMapping("/credits/me") + public ApiResponse getBalance( + @AuthenticationPrincipal UserDetailsImpl userDetails + ) { + return ApiResponse.onSuccess( + "크레딧 잔액 조회에 성공했습니다.", + paymentService.getBalance(userDetails.getUser()) + ); + } + + @Operation(summary = "내 크레딧 거래 내역 조회", description = "충전/사용/환불/쿠폰 거래 내역을 조회합니다. type query parameter로 필터링할 수 있습니다.") + @GetMapping("/credits/me/transactions") + public ApiResponse> getTransactions( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @RequestParam(required = false) CreditTransactionType type + ) { + return ApiResponse.onSuccess( + "크레딧 거래 내역 조회에 성공했습니다.", + paymentService.getTransactions(userDetails.getUser(), type) + ); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/request/PaymentConfirmRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/request/PaymentConfirmRequest.java new file mode 100644 index 0000000..1280d4e --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/request/PaymentConfirmRequest.java @@ -0,0 +1,16 @@ +package com.jobdri.jobdri_api.domain.payment.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Positive; + +public record PaymentConfirmRequest( + @NotBlank(message = "paymentKey는 필수입니다.") + String paymentKey, + + @NotBlank(message = "orderId는 필수입니다.") + String orderId, + + @Positive(message = "결제 금액은 1원 이상이어야 합니다.") + int amount +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/request/PaymentPrepareRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/request/PaymentPrepareRequest.java new file mode 100644 index 0000000..2ff935f --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/request/PaymentPrepareRequest.java @@ -0,0 +1,9 @@ +package com.jobdri.jobdri_api.domain.payment.dto.request; + +import jakarta.validation.constraints.NotBlank; + +public record PaymentPrepareRequest( + @NotBlank(message = "플랜 코드는 필수입니다.") + String planCode +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/CreditBalanceResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/CreditBalanceResponse.java new file mode 100644 index 0000000..4b9a474 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/CreditBalanceResponse.java @@ -0,0 +1,6 @@ +package com.jobdri.jobdri_api.domain.payment.dto.response; + +public record CreditBalanceResponse( + int creditBalance +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/CreditPlanResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/CreditPlanResponse.java new file mode 100644 index 0000000..88898c8 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/CreditPlanResponse.java @@ -0,0 +1,21 @@ +package com.jobdri.jobdri_api.domain.payment.dto.response; + +import com.jobdri.jobdri_api.domain.payment.entity.CreditPlan; + +public record CreditPlanResponse( + String planCode, + String name, + int creditAmount, + int price, + boolean recommended +) { + public static CreditPlanResponse from(CreditPlan plan) { + return new CreditPlanResponse( + plan.getCode(), + plan.getName(), + plan.getCreditAmount(), + plan.getPrice(), + plan.isRecommended() + ); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/CreditTransactionResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/CreditTransactionResponse.java new file mode 100644 index 0000000..c1f9126 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/CreditTransactionResponse.java @@ -0,0 +1,28 @@ +package com.jobdri.jobdri_api.domain.payment.dto.response; + +import com.jobdri.jobdri_api.domain.payment.entity.CreditTransaction; +import com.jobdri.jobdri_api.domain.payment.entity.CreditTransactionType; + +import java.time.LocalDateTime; + +public record CreditTransactionResponse( + Long transactionId, + CreditTransactionType type, + int amount, + int balanceAfter, + String description, + String referenceId, + LocalDateTime createdAt +) { + public static CreditTransactionResponse from(CreditTransaction transaction) { + return new CreditTransactionResponse( + transaction.getId(), + transaction.getType(), + transaction.getAmount(), + transaction.getBalanceAfter(), + transaction.getDescription(), + transaction.getReferenceId(), + transaction.getCreatedAt() + ); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/PaymentConfirmResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/PaymentConfirmResponse.java new file mode 100644 index 0000000..465ff1e --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/PaymentConfirmResponse.java @@ -0,0 +1,26 @@ +package com.jobdri.jobdri_api.domain.payment.dto.response; + +import com.jobdri.jobdri_api.domain.payment.entity.Payment; +import com.jobdri.jobdri_api.domain.payment.entity.PaymentStatus; + +public record PaymentConfirmResponse( + Long paymentId, + String orderId, + String paymentKey, + PaymentStatus status, + int creditAmount, + int amount, + int creditBalance +) { + public static PaymentConfirmResponse of(Payment payment, int creditBalance) { + return new PaymentConfirmResponse( + payment.getId(), + payment.getOrderId(), + payment.getPaymentKey(), + payment.getStatus(), + payment.getCreditAmount(), + payment.getPrice(), + creditBalance + ); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/PaymentPrepareResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/PaymentPrepareResponse.java new file mode 100644 index 0000000..1d55905 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/PaymentPrepareResponse.java @@ -0,0 +1,25 @@ +package com.jobdri.jobdri_api.domain.payment.dto.response; + +import com.jobdri.jobdri_api.domain.payment.entity.Payment; + +public record PaymentPrepareResponse( + Long paymentId, + String orderId, + String orderName, + int amount, + int creditAmount, + String clientKey, + String customerEmail +) { + public static PaymentPrepareResponse of(Payment payment, String clientKey) { + return new PaymentPrepareResponse( + payment.getId(), + payment.getOrderId(), + payment.getContent(), + payment.getPrice(), + payment.getCreditAmount(), + clientKey, + payment.getUser().getEmail() + ); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/toss/TossPaymentConfirmRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/toss/TossPaymentConfirmRequest.java new file mode 100644 index 0000000..b445a0c --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/toss/TossPaymentConfirmRequest.java @@ -0,0 +1,8 @@ +package com.jobdri.jobdri_api.domain.payment.dto.toss; + +public record TossPaymentConfirmRequest( + String paymentKey, + String orderId, + int amount +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/toss/TossPaymentConfirmResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/toss/TossPaymentConfirmResponse.java new file mode 100644 index 0000000..20665cd --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/toss/TossPaymentConfirmResponse.java @@ -0,0 +1,11 @@ +package com.jobdri.jobdri_api.domain.payment.dto.toss; + +public record TossPaymentConfirmResponse( + String paymentKey, + String orderId, + String orderName, + String status, + Integer totalAmount, + String method +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/CreditPlan.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/CreditPlan.java new file mode 100644 index 0000000..6256b56 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/CreditPlan.java @@ -0,0 +1,38 @@ +package com.jobdri.jobdri_api.domain.payment.entity; + +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import lombok.Getter; + +import java.util.Arrays; + +@Getter +public enum CreditPlan { + ONE_TIME("ONE_TIME", "1회권", 1, 2500, false), + FIVE_TIMES("FIVE_TIMES", "5회권", 5, 11500, true), + TEN_TIMES("TEN_TIMES", "10회권", 10, 19900, false); + + private final String code; + private final String name; + private final int creditAmount; + private final int price; + private final boolean recommended; + + CreditPlan(String code, String name, int creditAmount, int price, boolean recommended) { + this.code = code; + this.name = name; + this.creditAmount = creditAmount; + this.price = price; + this.recommended = recommended; + } + + public static CreditPlan from(String code) { + return Arrays.stream(values()) + .filter(plan -> plan.code.equalsIgnoreCase(code)) + .findFirst() + .orElseThrow(() -> new GeneralException( + GeneralErrorCode.INVALID_PARAMETER, + "지원하지 않는 크레딧 플랜입니다. planCode=" + code + )); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/CreditTransaction.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/CreditTransaction.java new file mode 100644 index 0000000..260fa26 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/CreditTransaction.java @@ -0,0 +1,61 @@ +package com.jobdri.jobdri_api.domain.payment.entity; + +import com.jobdri.jobdri_api.domain.user.entity.User; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder(access = AccessLevel.PRIVATE) +@Table(name = "credit_transactions") +public class CreditTransaction { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private CreditTransactionType type; + + @Column(nullable = false) + private int amount; + + @Column(nullable = false) + private int balanceAfter; + + @Column(nullable = false) + private String description; + + private String referenceId; + + @Column(nullable = false) + private LocalDateTime createdAt; + + public static CreditTransaction create( + User user, + CreditTransactionType type, + int amount, + int balanceAfter, + String description, + String referenceId + ) { + return CreditTransaction.builder() + .user(user) + .type(type) + .amount(amount) + .balanceAfter(balanceAfter) + .description(description) + .referenceId(referenceId) + .createdAt(LocalDateTime.now()) + .build(); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/CreditTransactionType.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/CreditTransactionType.java new file mode 100644 index 0000000..6457535 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/CreditTransactionType.java @@ -0,0 +1,8 @@ +package com.jobdri.jobdri_api.domain.payment.entity; + +public enum CreditTransactionType { + CHARGE, + USE, + REFUND, + COUPON +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/Payment.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/Payment.java index b689591..2797182 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/Payment.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/Payment.java @@ -25,6 +25,18 @@ public class Payment { @Column(nullable = false) private String content; + @Column(unique = true) + private String orderId; + + @Column(unique = true) + private String paymentKey; + + @Column + private String planCode; + + @Column(nullable = false) + private int creditAmount; + @Column(nullable = false) private int price; @@ -35,18 +47,32 @@ public class Payment { @Column(nullable = false) private LocalDateTime createdAt; - public static Payment createPending(User user, String content, int price) { + private LocalDateTime approvedAt; + + public static Payment createPending( + User user, + String content, + String orderId, + String planCode, + int creditAmount, + int price + ) { return Payment.builder() .user(user) .content(content) + .orderId(orderId) + .planCode(planCode) + .creditAmount(creditAmount) .price(price) .status(PaymentStatus.PENDING) .createdAt(LocalDateTime.now()) .build(); } - public void complete() { + public void complete(String paymentKey) { + this.paymentKey = paymentKey; this.status = PaymentStatus.COMPLETED; + this.approvedAt = LocalDateTime.now(); } public void fail() { diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/repository/CreditTransactionRepository.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/repository/CreditTransactionRepository.java new file mode 100644 index 0000000..e5c9655 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/repository/CreditTransactionRepository.java @@ -0,0 +1,12 @@ +package com.jobdri.jobdri_api.domain.payment.repository; + +import com.jobdri.jobdri_api.domain.payment.entity.CreditTransaction; +import com.jobdri.jobdri_api.domain.payment.entity.CreditTransactionType; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface CreditTransactionRepository extends JpaRepository { + List findAllByUserIdOrderByCreatedAtDescIdDesc(Long userId); + List findAllByUserIdAndTypeOrderByCreatedAtDescIdDesc(Long userId, CreditTransactionType type); +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/repository/PaymentRepository.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/repository/PaymentRepository.java index 8a164be..e4c5faf 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/repository/PaymentRepository.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/repository/PaymentRepository.java @@ -5,8 +5,10 @@ import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; +import java.util.Optional; public interface PaymentRepository extends JpaRepository { List findAllByUserId(Long userId); List findAllByStatus(PaymentStatus status); + Optional findByOrderId(String orderId); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/CreditService.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/CreditService.java new file mode 100644 index 0000000..5040911 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/CreditService.java @@ -0,0 +1,77 @@ +package com.jobdri.jobdri_api.domain.payment.service; + +import com.jobdri.jobdri_api.domain.payment.entity.CreditTransaction; +import com.jobdri.jobdri_api.domain.payment.entity.CreditTransactionType; +import com.jobdri.jobdri_api.domain.payment.repository.CreditTransactionRepository; +import com.jobdri.jobdri_api.domain.user.entity.User; +import com.jobdri.jobdri_api.domain.user.repository.UserRepository; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class CreditService { + + private final UserRepository userRepository; + private final CreditTransactionRepository creditTransactionRepository; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public int charge(User user, int amount, String description, String referenceId) { + User managedUser = getManagedUser(user); + managedUser.increaseCredit(amount); + saveTransaction(managedUser, CreditTransactionType.CHARGE, amount, description, referenceId); + return managedUser.getCredit(); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public int use(User user, int amount, String description, String referenceId) { + User managedUser = getManagedUser(user); + try { + managedUser.decreaseCredit(amount); + } catch (IllegalArgumentException e) { + throw new GeneralException(GeneralErrorCode.INSUFFICIENT_CREDIT, "크레딧이 부족합니다."); + } + saveTransaction(managedUser, CreditTransactionType.USE, -amount, description, referenceId); + return managedUser.getCredit(); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public int refund(User user, int amount, String description, String referenceId) { + User managedUser = getManagedUser(user); + managedUser.increaseCredit(amount); + saveTransaction(managedUser, CreditTransactionType.REFUND, amount, description, referenceId); + return managedUser.getCredit(); + } + + private void saveTransaction( + User user, + CreditTransactionType type, + int amount, + String description, + String referenceId + ) { + creditTransactionRepository.save(CreditTransaction.create( + user, + type, + amount, + user.getCredit(), + description, + referenceId + )); + } + + private User getManagedUser(User user) { + if (user == null || user.getId() == null) { + throw new GeneralException(GeneralErrorCode.MISSING_AUTH_INFO, "인증 정보가 누락되었습니다."); + } + return userRepository.findById(user.getId()) + .orElseThrow(() -> new GeneralException( + GeneralErrorCode.USER_NOT_FOUND, + "해당 유저를 찾을 수 없습니다. userId=" + user.getId() + )); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentService.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentService.java new file mode 100644 index 0000000..c5033e8 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentService.java @@ -0,0 +1,125 @@ +package com.jobdri.jobdri_api.domain.payment.service; + +import com.jobdri.jobdri_api.domain.payment.dto.request.PaymentConfirmRequest; +import com.jobdri.jobdri_api.domain.payment.dto.request.PaymentPrepareRequest; +import com.jobdri.jobdri_api.domain.payment.dto.response.*; +import com.jobdri.jobdri_api.domain.payment.dto.toss.TossPaymentConfirmResponse; +import com.jobdri.jobdri_api.domain.payment.entity.CreditPlan; +import com.jobdri.jobdri_api.domain.payment.entity.CreditTransactionType; +import com.jobdri.jobdri_api.domain.payment.entity.Payment; +import com.jobdri.jobdri_api.domain.payment.entity.PaymentStatus; +import com.jobdri.jobdri_api.domain.payment.repository.CreditTransactionRepository; +import com.jobdri.jobdri_api.domain.payment.repository.PaymentRepository; +import com.jobdri.jobdri_api.domain.user.entity.User; +import com.jobdri.jobdri_api.domain.user.service.UserService; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Arrays; +import java.util.List; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PaymentService { + + private final UserService userService; + private final PaymentRepository paymentRepository; + private final CreditTransactionRepository creditTransactionRepository; + private final TossPaymentClient tossPaymentClient; + private final CreditService creditService; + + @Value("${payment.toss.client-key:}") + private String tossClientKey; + + public List getPlans() { + return Arrays.stream(CreditPlan.values()) + .map(CreditPlanResponse::from) + .toList(); + } + + @Transactional + public PaymentPrepareResponse prepare(User user, PaymentPrepareRequest request) { + User validatedUser = userService.validateUser(user); + CreditPlan plan = CreditPlan.from(request.planCode()); + String orderId = "jobdri-" + UUID.randomUUID(); + Payment payment = paymentRepository.save(Payment.createPending( + validatedUser, + "JobDri 크레딧 " + plan.getName(), + orderId, + plan.getCode(), + plan.getCreditAmount(), + plan.getPrice() + )); + + return PaymentPrepareResponse.of(payment, tossClientKey); + } + + @Transactional + public PaymentConfirmResponse confirm(User user, PaymentConfirmRequest request) { + User validatedUser = userService.validateUser(user); + Payment payment = paymentRepository.findByOrderId(request.orderId()) + .orElseThrow(() -> new GeneralException( + GeneralErrorCode.PAYMENT_NOT_FOUND, + "결제 정보를 찾을 수 없습니다. orderId=" + request.orderId() + )); + + if (!payment.getUser().getId().equals(validatedUser.getId())) { + throw new GeneralException(GeneralErrorCode.FORBIDDEN, "해당 결제에 접근할 수 없습니다."); + } + if (payment.getStatus() != PaymentStatus.PENDING) { + throw new GeneralException(GeneralErrorCode.PAYMENT_ALREADY_PROCESSED, "이미 처리된 결제입니다."); + } + if (payment.getPrice() != request.amount()) { + throw new GeneralException(GeneralErrorCode.PAYMENT_AMOUNT_MISMATCH, "결제 금액이 일치하지 않습니다."); + } + + TossPaymentConfirmResponse tossResponse = + tossPaymentClient.confirm(request.paymentKey(), request.orderId(), request.amount()); + validateTossResponse(request, tossResponse); + + payment.complete(request.paymentKey()); + int creditBalance = creditService.charge( + validatedUser, + payment.getCreditAmount(), + payment.getContent(), + payment.getOrderId() + ); + + return PaymentConfirmResponse.of(payment, creditBalance); + } + + public CreditBalanceResponse getBalance(User user) { + User validatedUser = userService.validateUser(user); + return new CreditBalanceResponse(validatedUser.getCredit()); + } + + public List getTransactions(User user, CreditTransactionType type) { + User validatedUser = userService.validateUser(user); + if (type == null) { + return creditTransactionRepository.findAllByUserIdOrderByCreatedAtDescIdDesc(validatedUser.getId()).stream() + .map(CreditTransactionResponse::from) + .toList(); + } + + return creditTransactionRepository + .findAllByUserIdAndTypeOrderByCreatedAtDescIdDesc(validatedUser.getId(), type).stream() + .map(CreditTransactionResponse::from) + .toList(); + } + + private void validateTossResponse(PaymentConfirmRequest request, TossPaymentConfirmResponse response) { + if (response == null + || !request.orderId().equals(response.orderId()) + || !request.paymentKey().equals(response.paymentKey()) + || response.totalAmount() == null + || response.totalAmount() != request.amount()) { + throw new GeneralException(GeneralErrorCode.PAYMENT_CONFIRM_FAILED, "결제 승인 응답 검증에 실패했습니다."); + } + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/TossPaymentClient.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/TossPaymentClient.java new file mode 100644 index 0000000..7e9ab47 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/TossPaymentClient.java @@ -0,0 +1,55 @@ +package com.jobdri.jobdri_api.domain.payment.service; + +import com.jobdri.jobdri_api.domain.payment.dto.toss.TossPaymentConfirmRequest; +import com.jobdri.jobdri_api.domain.payment.dto.toss.TossPaymentConfirmResponse; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientException; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +@Component +@RequiredArgsConstructor +public class TossPaymentClient { + + private final RestClient.Builder restClientBuilder; + + @Value("${payment.toss.secret-key:}") + private String secretKey; + + @Value("${payment.toss.base-url:https://api.tosspayments.com}") + private String baseUrl; + + public TossPaymentConfirmResponse confirm(String paymentKey, String orderId, int amount) { + try { + return restClientBuilder + .baseUrl(baseUrl) + .build() + .post() + .uri("/v1/payments/confirm") + .header(HttpHeaders.AUTHORIZATION, authorizationHeader()) + .header("Idempotency-Key", orderId) + .contentType(MediaType.APPLICATION_JSON) + .body(new TossPaymentConfirmRequest(paymentKey, orderId, amount)) + .retrieve() + .body(TossPaymentConfirmResponse.class); + } catch (RestClientException e) { + throw new GeneralException( + GeneralErrorCode.PAYMENT_CONFIRM_FAILED, + "토스페이먼츠 결제 승인에 실패했습니다." + ); + } + } + + private String authorizationHeader() { + String credential = secretKey + ":"; + return "Basic " + Base64.getEncoder().encodeToString(credential.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/global/apiPayload/code/GeneralErrorCode.java b/src/main/java/com/jobdri/jobdri_api/global/apiPayload/code/GeneralErrorCode.java index 9a5cda2..26a1335 100644 --- a/src/main/java/com/jobdri/jobdri_api/global/apiPayload/code/GeneralErrorCode.java +++ b/src/main/java/com/jobdri/jobdri_api/global/apiPayload/code/GeneralErrorCode.java @@ -44,6 +44,13 @@ public enum GeneralErrorCode implements BaseErrorCode { QUESTION_NOT_FOUND(HttpStatus.NOT_FOUND, "QUESTION_4041", "문항을 찾을 수 없습니다."), ANALYSIS_NOT_FOUND(HttpStatus.NOT_FOUND, "ANALYSIS_4041", "자소서 분석 결과를 찾을 수 없습니다."), + // 결제/크레딧 에러 + PAYMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "PAYMENT_4041", "결제 정보를 찾을 수 없습니다."), + PAYMENT_AMOUNT_MISMATCH(HttpStatus.BAD_REQUEST, "PAYMENT_4001", "결제 금액이 일치하지 않습니다."), + PAYMENT_ALREADY_PROCESSED(HttpStatus.BAD_REQUEST, "PAYMENT_4002", "이미 처리된 결제입니다."), + PAYMENT_CONFIRM_FAILED(HttpStatus.BAD_GATEWAY, "PAYMENT_5021", "결제 승인에 실패했습니다."), + INSUFFICIENT_CREDIT(HttpStatus.PAYMENT_REQUIRED, "CREDIT_4021", "크레딧이 부족합니다."), + // 유저 에러 USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_4041", "유저를 찾을 수 없습니다."); diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml index 170de28..6f38965 100644 --- a/src/main/resources/application-dev.yaml +++ b/src/main/resources/application-dev.yaml @@ -84,6 +84,12 @@ openai: model: job-posting-extractor: ${OPENAI_JOB_POSTING_MODEL:gpt-4o-mini} +payment: + toss: + client-key: ${TOSS_CLIENT_KEY:} + secret-key: ${TOSS_SECRET_KEY:} + base-url: ${TOSS_BASE_URL:https://api.tosspayments.com} + job-posting: ingest: classification-confidence-threshold: ${JOB_POSTING_CLASSIFICATION_CONFIDENCE_THRESHOLD:0.65} diff --git a/src/main/resources/application-prod.yaml b/src/main/resources/application-prod.yaml index b20ffc4..cd43361 100644 --- a/src/main/resources/application-prod.yaml +++ b/src/main/resources/application-prod.yaml @@ -96,6 +96,12 @@ openai: model: job-posting-extractor: ${OPENAI_JOB_POSTING_MODEL:gpt-4o-mini} +payment: + toss: + client-key: ${TOSS_CLIENT_KEY} + secret-key: ${TOSS_SECRET_KEY} + base-url: ${TOSS_BASE_URL:https://api.tosspayments.com} + job-posting: ingest: classification-confidence-threshold: ${JOB_POSTING_CLASSIFICATION_CONFIDENCE_THRESHOLD:0.65} diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisServiceTest.java index fb9852b..86a8b17 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisServiceTest.java @@ -31,7 +31,6 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.ActiveProfiles; -import org.springframework.transaction.annotation.Transactional; import java.util.List; @@ -42,7 +41,6 @@ @SpringBootTest @ActiveProfiles("test") -@Transactional class AnalysisServiceTest { @Autowired @@ -114,7 +112,8 @@ void analyzeSavesAnalysis() { assertThat(response.questions().get(0).analyses().get(0).start()).isEqualTo(0); assertThat(response.questions().get(0).analyses().get(0).end()) .isEqualTo("Spring Boot API를 개발했습니다.".length()); - assertThat(mockApply.getStatus()).isEqualTo(MockApplyStatus.COMPLETED); + assertThat(mockApplyRepository.findById(mockApply.getId()).orElseThrow().getStatus()) + .isEqualTo(MockApplyStatus.COMPLETED); assertThat(analysisRepository.findByMockApplyId(mockApply.getId())).isPresent(); } @@ -254,7 +253,9 @@ void getAnalysis() { } private User saveUser(String email) { - return userRepository.save(User.signup("테스트 사용자", email, "encoded-password")); + User user = User.signup("테스트 사용자", email, "encoded-password"); + user.increaseCredit(10); + return userRepository.save(user); } private MockApply saveMockApply(User user) { diff --git a/src/test/java/com/jobdri/jobdri_api/domain/payment/service/PaymentServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/payment/service/PaymentServiceTest.java new file mode 100644 index 0000000..bcc2c65 --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/payment/service/PaymentServiceTest.java @@ -0,0 +1,123 @@ +package com.jobdri.jobdri_api.domain.payment.service; + +import com.jobdri.jobdri_api.domain.payment.dto.request.PaymentConfirmRequest; +import com.jobdri.jobdri_api.domain.payment.dto.request.PaymentPrepareRequest; +import com.jobdri.jobdri_api.domain.payment.dto.response.PaymentConfirmResponse; +import com.jobdri.jobdri_api.domain.payment.dto.response.PaymentPrepareResponse; +import com.jobdri.jobdri_api.domain.payment.dto.toss.TossPaymentConfirmResponse; +import com.jobdri.jobdri_api.domain.payment.entity.CreditPlan; +import com.jobdri.jobdri_api.domain.payment.entity.CreditTransactionType; +import com.jobdri.jobdri_api.domain.payment.entity.PaymentStatus; +import com.jobdri.jobdri_api.domain.payment.repository.CreditTransactionRepository; +import com.jobdri.jobdri_api.domain.payment.repository.PaymentRepository; +import com.jobdri.jobdri_api.domain.user.entity.User; +import com.jobdri.jobdri_api.domain.user.repository.UserRepository; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.ActiveProfiles; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +@SpringBootTest +@ActiveProfiles("test") +class PaymentServiceTest { + + @Autowired + private PaymentService paymentService; + + @Autowired + private UserRepository userRepository; + + @Autowired + private PaymentRepository paymentRepository; + + @Autowired + private CreditTransactionRepository creditTransactionRepository; + + @MockBean + private TossPaymentClient tossPaymentClient; + + @Test + @DisplayName("크레딧 플랜 목록을 조회한다") + void getPlans() { + var plans = paymentService.getPlans(); + + assertThat(plans).hasSize(3); + assertThat(plans) + .extracting("planCode", "creditAmount", "price") + .contains( + org.assertj.core.groups.Tuple.tuple("ONE_TIME", 1, 2500), + org.assertj.core.groups.Tuple.tuple("FIVE_TIMES", 5, 11500), + org.assertj.core.groups.Tuple.tuple("TEN_TIMES", 10, 19900) + ); + } + + @Test + @DisplayName("결제 준비 시 PENDING 결제 정보를 생성한다") + void prepare() { + User user = saveUser("payment-prepare@example.com"); + + PaymentPrepareResponse response = paymentService.prepare(user, new PaymentPrepareRequest("FIVE_TIMES")); + + assertThat(response.orderId()).startsWith("jobdri-"); + assertThat(response.orderName()).isEqualTo("JobDri 크레딧 5회권"); + assertThat(response.amount()).isEqualTo(11500); + assertThat(response.creditAmount()).isEqualTo(5); + assertThat(paymentRepository.findByOrderId(response.orderId())).isPresent(); + } + + @Test + @DisplayName("토스 결제 승인 성공 시 크레딧을 충전하고 거래 내역을 저장한다") + void confirm() { + User user = saveUser("payment-confirm@example.com"); + PaymentPrepareResponse prepared = paymentService.prepare(user, new PaymentPrepareRequest("ONE_TIME")); + when(tossPaymentClient.confirm("payment-key", prepared.orderId(), 2500)) + .thenReturn(new TossPaymentConfirmResponse( + "payment-key", + prepared.orderId(), + prepared.orderName(), + "DONE", + 2500, + "CARD" + )); + + PaymentConfirmResponse response = paymentService.confirm( + user, + new PaymentConfirmRequest("payment-key", prepared.orderId(), 2500) + ); + + assertThat(response.status()).isEqualTo(PaymentStatus.COMPLETED); + assertThat(response.creditBalance()).isEqualTo(2); + assertThat(userRepository.findById(user.getId()).orElseThrow().getCredit()).isEqualTo(2); + assertThat(creditTransactionRepository.findAllByUserIdAndTypeOrderByCreatedAtDescIdDesc( + user.getId(), + CreditTransactionType.CHARGE + )).hasSize(1); + } + + @Test + @DisplayName("결제 승인 요청 금액이 준비 금액과 다르면 예외를 던진다") + void confirmThrowsWhenAmountMismatch() { + User user = saveUser("payment-amount-mismatch@example.com"); + PaymentPrepareResponse prepared = paymentService.prepare(user, new PaymentPrepareRequest(CreditPlan.ONE_TIME.getCode())); + + assertThatThrownBy(() -> paymentService.confirm( + user, + new PaymentConfirmRequest("payment-key", prepared.orderId(), 1000) + )) + .isInstanceOf(GeneralException.class) + .extracting("code") + .isEqualTo(GeneralErrorCode.PAYMENT_AMOUNT_MISMATCH); + } + + private User saveUser(String email) { + return userRepository.save(User.signup("테스트 사용자", email, "encoded-password")); + } +}