-
Notifications
You must be signed in to change notification settings - Fork 0
[Feat] 토스페이먼츠 기반 크레딧 결제 구현 #70
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<List<CreditPlanResponse>> getPlans() { | ||
| return ApiResponse.onSuccess("크레딧 가격 플랜 조회에 성공했습니다.", paymentService.getPlans()); | ||
| } | ||
|
|
||
| @Operation(summary = "토스 결제 준비", description = "선택한 크레딧 플랜 기준으로 결제 주문을 생성합니다.") | ||
| @PostMapping("/prepare") | ||
| public ApiResponse<PaymentPrepareResponse> 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<PaymentConfirmResponse> confirm( | ||
| @AuthenticationPrincipal UserDetailsImpl userDetails, | ||
| @Valid @RequestBody PaymentConfirmRequest request | ||
| ) { | ||
| return ApiResponse.onSuccess( | ||
| "결제가 완료되었습니다.", | ||
| paymentService.confirm(userDetails.getUser(), request) | ||
| ); | ||
| } | ||
|
|
||
| @Operation(summary = "내 크레딧 잔액 조회", description = "로그인 사용자의 현재 크레딧 잔액을 조회합니다.") | ||
| @GetMapping("/credits/me") | ||
| public ApiResponse<CreditBalanceResponse> getBalance( | ||
| @AuthenticationPrincipal UserDetailsImpl userDetails | ||
| ) { | ||
| return ApiResponse.onSuccess( | ||
| "크레딧 잔액 조회에 성공했습니다.", | ||
| paymentService.getBalance(userDetails.getUser()) | ||
| ); | ||
| } | ||
|
|
||
| @Operation(summary = "내 크레딧 거래 내역 조회", description = "충전/사용/환불/쿠폰 거래 내역을 조회합니다. type query parameter로 필터링할 수 있습니다.") | ||
| @GetMapping("/credits/me/transactions") | ||
| public ApiResponse<List<CreditTransactionResponse>> getTransactions( | ||
| @AuthenticationPrincipal UserDetailsImpl userDetails, | ||
| @RequestParam(required = false) CreditTransactionType type | ||
| ) { | ||
| return ApiResponse.onSuccess( | ||
| "크레딧 거래 내역 조회에 성공했습니다.", | ||
| paymentService.getTransactions(userDetails.getUser(), type) | ||
| ); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| ) { | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| ) { | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| package com.jobdri.jobdri_api.domain.payment.dto.response; | ||
|
|
||
| public record CreditBalanceResponse( | ||
| int creditBalance | ||
| ) { | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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() | ||
| ); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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() | ||
| ); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| ); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -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() | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Potential NPE on user access.
🛡️ Proposed defensive fix- payment.getUser().getEmail()
+ payment.getUser() != null ? payment.getUser().getEmail() : nullAlternatively, if user is always required, ensure the Payment entity enforces this with 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||
| ); | ||||||
| } | ||||||
| } | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| package com.jobdri.jobdri_api.domain.payment.dto.toss; | ||
|
|
||
| public record TossPaymentConfirmRequest( | ||
| String paymentKey, | ||
| String orderId, | ||
| int amount | ||
| ) { | ||
|
Comment on lines
+3
to
+7
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win Add validation annotations for external API request. This DTO is sent to the TOSS Payments API but lacks validation. Consider adding Bean Validation annotations to prevent invalid data:
🔒 Proposed validation+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Positive;
+
public record TossPaymentConfirmRequest(
+ `@NotBlank`
String paymentKey,
+ `@NotBlank`
String orderId,
+ `@Positive`
int amount
) {
}🤖 Prompt for AI Agents |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| ) { | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| )); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pre-committing the credit deduction here can permanently strand user credits.
creditService.use(...)is committed before the external LLM call, and the refund is only best-effort inside this thread'scatch. If the process dies, the request times out, or the node is terminated between those two steps, the user keeps the deducted credit with no analysis result and no refund. For this flow, the charge needs to stay in the outer transaction or be modeled as a reservation/finalize flow with durable reconciliation.Also applies to: 82-84
🤖 Prompt for AI Agents