diff --git a/ops/db/migrations/20260618_credit_transactions_unique_reference.sql b/ops/db/migrations/20260618_credit_transactions_unique_reference.sql new file mode 100644 index 0000000..a6c9dc3 --- /dev/null +++ b/ops/db/migrations/20260618_credit_transactions_unique_reference.sql @@ -0,0 +1,50 @@ +-- Manual migration to enforce credit transaction idempotency at the database level. +-- Run after backing up the database. + +-- Remove duplicate rows that violate the intended uniqueness rule and keep the earliest row. +WITH ranked_duplicates AS ( + SELECT + id, + ROW_NUMBER() OVER ( + PARTITION BY user_id, type, reference_id + ORDER BY id + ) AS duplicate_rank + FROM credit_transactions + WHERE reference_id IS NOT NULL +) +DELETE FROM credit_transactions +WHERE id IN ( + SELECT id + FROM ranked_duplicates + WHERE duplicate_rank > 1 +); + +-- Abort before adding the constraint if duplicates still remain for any reason. +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM credit_transactions + WHERE reference_id IS NOT NULL + GROUP BY user_id, type, reference_id + HAVING COUNT(*) > 1 + ) THEN + RAISE EXCEPTION + 'Duplicate credit_transactions remain for (user_id, type, reference_id); aborting unique constraint creation.'; + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.table_constraints + WHERE table_schema = current_schema() + AND table_name = 'credit_transactions' + AND constraint_name = 'uk_credit_transactions_user_type_reference' + ) THEN + ALTER TABLE credit_transactions + ADD CONSTRAINT uk_credit_transactions_user_type_reference + UNIQUE (user_id, type, reference_id); + END IF; +END $$; 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 index 6c8d647..84ac802 100644 --- 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 @@ -10,7 +10,15 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor(access = AccessLevel.PRIVATE) @Builder(access = AccessLevel.PRIVATE) -@Table(name = "credit_transactions") +@Table( + name = "credit_transactions", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_credit_transactions_user_type_reference", + columnNames = {"user_id", "type", "reference_id"} + ) + } +) public class CreditTransaction extends CreatedAtEntity { @Id @@ -34,6 +42,7 @@ public class CreditTransaction extends CreatedAtEntity { @Column(nullable = false) private String description; + @Column(nullable = false, name = "reference_id") private String referenceId; public static CreditTransaction create( 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 dc31d8e..962c4ce 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 @@ -66,6 +66,11 @@ public static Payment createPending( .build(); } + public void markProcessing(String paymentKey) { + this.paymentKey = paymentKey; + this.status = PaymentStatus.PROCESSING; + } + public void complete(String paymentKey) { this.paymentKey = paymentKey; this.status = PaymentStatus.COMPLETED; @@ -75,4 +80,12 @@ public void complete(String paymentKey) { public void fail() { this.status = PaymentStatus.FAILED; } + + public boolean belongsTo(Long userId) { + return user != null && user.getId() != null && user.getId().equals(userId); + } + + public boolean hasPaymentKey(String paymentKey) { + return this.paymentKey != null && this.paymentKey.equals(paymentKey); + } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/PaymentStatus.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/PaymentStatus.java index 30ca04f..c274f83 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/PaymentStatus.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/PaymentStatus.java @@ -5,6 +5,7 @@ @Getter public enum PaymentStatus { PENDING, + PROCESSING, FAILED, COMPLETED } 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 index e5c9655..b2c5782 100644 --- 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 @@ -5,8 +5,10 @@ import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; +import java.util.Optional; public interface CreditTransactionRepository extends JpaRepository { List findAllByUserIdOrderByCreatedAtDescIdDesc(Long userId); List findAllByUserIdAndTypeOrderByCreatedAtDescIdDesc(Long userId, CreditTransactionType type); + Optional findByUserIdAndTypeAndReferenceId(Long userId, CreditTransactionType type, String referenceId); } 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 index c5443f4..9ce4914 100644 --- 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 @@ -10,6 +10,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; @Service @RequiredArgsConstructor @@ -20,32 +21,33 @@ public class CreditService { @Transactional public int charge(User user, int amount, String description, String referenceId) { - validatePositiveAmount(amount); - User managedUser = getManagedUser(user); - managedUser.increaseCredit(amount); - saveTransaction(managedUser, CreditTransactionType.CHARGE, amount, description, referenceId); - return managedUser.getCredit(); + return apply(user, CreditTransactionType.CHARGE, amount, description, referenceId); } @Transactional public int use(User user, int amount, String description, String referenceId) { - validatePositiveAmount(amount); - 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(); + return apply(user, CreditTransactionType.USE, amount, description, referenceId); } @Transactional public int refund(User user, int amount, String description, String referenceId) { + return apply(user, CreditTransactionType.REFUND, amount, description, referenceId); + } + + private int apply(User user, CreditTransactionType type, int amount, String description, String referenceId) { validatePositiveAmount(amount); + validateReferenceId(referenceId); User managedUser = getManagedUser(user); - managedUser.increaseCredit(amount); - saveTransaction(managedUser, CreditTransactionType.REFUND, amount, description, referenceId); + CreditTransaction existingTransaction = creditTransactionRepository + .findByUserIdAndTypeAndReferenceId(managedUser.getId(), type, referenceId) + .orElse(null); + if (existingTransaction != null) { + return existingTransaction.getBalanceAfter(); + } + + int transactionAmount = resolveTransactionAmount(type, amount); + applyCreditChange(managedUser, type, amount); + saveTransaction(managedUser, type, transactionAmount, description, referenceId); return managedUser.getCredit(); } @@ -55,6 +57,28 @@ private void validatePositiveAmount(int amount) { } } + private void validateReferenceId(String referenceId) { + if (!StringUtils.hasText(referenceId)) { + throw new GeneralException(GeneralErrorCode.INVALID_PARAMETER, "referenceId는 필수입니다."); + } + } + + private void applyCreditChange(User user, CreditTransactionType type, int amount) { + if (type == CreditTransactionType.USE) { + try { + user.decreaseCredit(amount); + } catch (IllegalArgumentException e) { + throw new GeneralException(GeneralErrorCode.INSUFFICIENT_CREDIT, "크레딧이 부족합니다."); + } + return; + } + user.increaseCredit(amount); + } + + private int resolveTransactionAmount(CreditTransactionType type, int amount) { + return type == CreditTransactionType.USE ? -amount : amount; + } + private void saveTransaction( User user, CreditTransactionType type, 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 index 8717820..7614133 100644 --- 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 @@ -7,7 +7,6 @@ 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; @@ -18,6 +17,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import java.util.Arrays; @@ -32,8 +32,8 @@ public class PaymentService { private final UserService userService; private final PaymentRepository paymentRepository; private final CreditTransactionRepository creditTransactionRepository; + private final PaymentTransactionService paymentTransactionService; private final TossPaymentClient tossPaymentClient; - private final CreditService creditService; @Value("${payment.toss.client-key:}") private String tossClientKey; @@ -68,38 +68,24 @@ public PaymentPrepareResponse prepare(User user, PaymentPrepareRequest request) return PaymentPrepareResponse.of(payment, tossClientKey); } - @Transactional + @Transactional(propagation = Propagation.NOT_SUPPORTED) public PaymentConfirmResponse confirm(User user, PaymentConfirmRequest request) { User validatedUser = userService.validateUser(user); - Payment payment = paymentRepository.findByOrderIdForUpdate(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, "결제 금액이 일치하지 않습니다."); + PaymentTransactionService.PaymentConfirmationStart start = + paymentTransactionService.startConfirmation(validatedUser.getId(), request); + if (start.alreadyCompleted()) { + return PaymentConfirmResponse.of(start.payment(), userService.getUser(validatedUser.getId()).getCredit()); } - 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); + try { + TossPaymentConfirmResponse tossResponse = + tossPaymentClient.confirm(request.paymentKey(), request.orderId(), request.amount()); + validateTossResponse(request, tossResponse); + } catch (RuntimeException e) { + paymentTransactionService.failConfirmation(validatedUser.getId(), request.orderId(), request.paymentKey()); + throw e; + } + return paymentTransactionService.completeConfirmation(validatedUser.getId(), request); } public CreditBalanceResponse getBalance(User user) { diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentTransactionService.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentTransactionService.java new file mode 100644 index 0000000..3881a35 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentTransactionService.java @@ -0,0 +1,99 @@ +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.response.PaymentConfirmResponse; +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.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.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class PaymentTransactionService { + + private final PaymentRepository paymentRepository; + private final UserService userService; + private final CreditService creditService; + + @Transactional + public PaymentConfirmationStart startConfirmation(Long userId, PaymentConfirmRequest request) { + Payment payment = getOwnedPaymentForUpdate(userId, request.orderId()); + validateAmount(payment, request.amount()); + + if (payment.getStatus() == PaymentStatus.COMPLETED) { + if (!payment.hasPaymentKey(request.paymentKey())) { + throw new GeneralException(GeneralErrorCode.PAYMENT_ALREADY_PROCESSED, "이미 처리된 결제입니다."); + } + return new PaymentConfirmationStart(payment, true); + } + if (payment.getStatus() == PaymentStatus.PROCESSING || payment.getStatus() == PaymentStatus.FAILED) { + throw new GeneralException(GeneralErrorCode.PAYMENT_ALREADY_PROCESSED, "이미 처리된 결제입니다."); + } + + payment.markProcessing(request.paymentKey()); + return new PaymentConfirmationStart(payment, false); + } + + @Transactional + public PaymentConfirmResponse completeConfirmation(Long userId, PaymentConfirmRequest request) { + Payment payment = getOwnedPaymentForUpdate(userId, request.orderId()); + validateAmount(payment, request.amount()); + if (!payment.hasPaymentKey(request.paymentKey())) { + throw new GeneralException(GeneralErrorCode.PAYMENT_CONFIRM_FAILED, "결제 승인 정보가 일치하지 않습니다."); + } + if (payment.getStatus() == PaymentStatus.COMPLETED) { + return PaymentConfirmResponse.of(payment, userService.getUser(userId).getCredit()); + } + if (payment.getStatus() != PaymentStatus.PROCESSING) { + throw new GeneralException(GeneralErrorCode.PAYMENT_ALREADY_PROCESSED, "이미 처리된 결제입니다."); + } + + User user = userService.getUser(userId); + payment.complete(request.paymentKey()); + int creditBalance = creditService.charge( + user, + payment.getCreditAmount(), + payment.getContent(), + payment.getOrderId() + ); + return PaymentConfirmResponse.of(payment, creditBalance); + } + + @Transactional + public void failConfirmation(Long userId, String orderId, String paymentKey) { + Payment payment = getOwnedPaymentForUpdate(userId, orderId); + if (!payment.hasPaymentKey(paymentKey)) { + return; + } + if (payment.getStatus() == PaymentStatus.PROCESSING) { + payment.fail(); + } + } + + private Payment getOwnedPaymentForUpdate(Long userId, String orderId) { + Payment payment = paymentRepository.findByOrderIdForUpdate(orderId) + .orElseThrow(() -> new GeneralException( + GeneralErrorCode.PAYMENT_NOT_FOUND, + "결제 정보를 찾을 수 없습니다. orderId=" + orderId + )); + if (!payment.belongsTo(userId)) { + throw new GeneralException(GeneralErrorCode.FORBIDDEN, "해당 결제에 접근할 수 없습니다."); + } + return payment; + } + + private void validateAmount(Payment payment, int amount) { + if (payment.getPrice() != amount) { + throw new GeneralException(GeneralErrorCode.PAYMENT_AMOUNT_MISMATCH, "결제 금액이 일치하지 않습니다."); + } + } + + public record PaymentConfirmationStart(Payment payment, boolean alreadyCompleted) { + } +} diff --git a/src/test/java/com/jobdri/jobdri_api/domain/payment/service/CreditServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/payment/service/CreditServiceTest.java new file mode 100644 index 0000000..2df4d58 --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/payment/service/CreditServiceTest.java @@ -0,0 +1,65 @@ +package com.jobdri.jobdri_api.domain.payment.service; + +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 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.test.context.ActiveProfiles; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@ActiveProfiles("test") +class CreditServiceTest { + + @Autowired + private CreditService creditService; + + @Autowired + private UserRepository userRepository; + + @Autowired + private CreditTransactionRepository creditTransactionRepository; + + @Test + @DisplayName("같은 referenceId로 크레딧 충전을 재시도해도 한 번만 반영한다") + void chargeIsIdempotentByReferenceId() { + User user = saveUser("credit-charge-idempotent@example.com"); + + int first = creditService.charge(user, 5, "테스트 충전", "payment-order-1"); + int second = creditService.charge(user, 5, "테스트 충전", "payment-order-1"); + + assertThat(first).isEqualTo(6); + assertThat(second).isEqualTo(6); + assertThat(userRepository.findById(user.getId()).orElseThrow().getCredit()).isEqualTo(6); + assertThat(creditTransactionRepository.findAllByUserIdAndTypeOrderByCreatedAtDescIdDesc( + user.getId(), + CreditTransactionType.CHARGE + )).hasSize(1); + } + + @Test + @DisplayName("같은 referenceId로 크레딧 사용을 재시도해도 한 번만 차감한다") + void useIsIdempotentByReferenceId() { + User user = saveUser("credit-use-idempotent@example.com"); + + int first = creditService.use(user, 1, "테스트 사용", "mockApplyId=101"); + int second = creditService.use(user, 1, "테스트 사용", "mockApplyId=101"); + + assertThat(first).isZero(); + assertThat(second).isZero(); + assertThat(userRepository.findById(user.getId()).orElseThrow().getCredit()).isZero(); + assertThat(creditTransactionRepository.findAllByUserIdAndTypeOrderByCreatedAtDescIdDesc( + user.getId(), + CreditTransactionType.USE + )).hasSize(1); + } + + private User saveUser(String email) { + return userRepository.save(User.signup("테스트 사용자", email, "encoded-password")); + } +} 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 index 3ff86d4..a94488f 100644 --- 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 @@ -112,6 +112,36 @@ void confirm() { )).hasSize(1); } + @Test + @DisplayName("이미 완료된 동일 결제 승인 재시도는 기존 결과를 반환하고 중복 충전하지 않는다") + void confirmReturnsExistingResultWhenAlreadyCompleted() { + User user = saveUser("payment-confirm-idempotent@example.com"); + PaymentPrepareResponse prepared = paymentService.prepare(user, new PaymentPrepareRequest("ONE_TIME")); + String paymentKey = "payment-key-" + prepared.orderId(); + when(tossPaymentClient.confirm(paymentKey, prepared.orderId(), 2500)) + .thenReturn(new TossPaymentConfirmResponse( + paymentKey, + prepared.orderId(), + prepared.orderName(), + "DONE", + 2500, + "CARD" + )); + PaymentConfirmRequest request = new PaymentConfirmRequest(paymentKey, prepared.orderId(), 2500); + + PaymentConfirmResponse first = paymentService.confirm(user, request); + PaymentConfirmResponse second = paymentService.confirm(user, request); + + assertThat(first.status()).isEqualTo(PaymentStatus.COMPLETED); + assertThat(second.status()).isEqualTo(PaymentStatus.COMPLETED); + assertThat(second.paymentId()).isEqualTo(first.paymentId()); + assertThat(userRepository.findById(user.getId()).orElseThrow().getCredit()).isEqualTo(2); + assertThat(creditTransactionRepository.findAllByUserIdAndTypeOrderByCreatedAtDescIdDesc( + user.getId(), + CreditTransactionType.CHARGE + )).hasSize(1); + } + @Test @DisplayName("결제 승인 요청 금액이 준비 금액과 다르면 예외를 던진다") void confirmThrowsWhenAmountMismatch() { @@ -127,6 +157,32 @@ void confirmThrowsWhenAmountMismatch() { .isEqualTo(GeneralErrorCode.PAYMENT_AMOUNT_MISMATCH); } + @Test + @DisplayName("토스 결제 승인 실패 시 결제 상태를 FAILED로 변경한다") + void confirmMarksPaymentAsFailedWhenTossConfirmFails() { + User user = saveUser("payment-confirm-fail@example.com"); + PaymentPrepareResponse prepared = paymentService.prepare(user, new PaymentPrepareRequest("ONE_TIME")); + String paymentKey = "payment-key-" + prepared.orderId(); + when(tossPaymentClient.confirm(paymentKey, prepared.orderId(), 2500)) + .thenThrow(new GeneralException(GeneralErrorCode.PAYMENT_CONFIRM_FAILED, "토스 승인 실패")); + + assertThatThrownBy(() -> paymentService.confirm( + user, + new PaymentConfirmRequest(paymentKey, prepared.orderId(), 2500) + )) + .isInstanceOf(GeneralException.class) + .extracting("code") + .isEqualTo(GeneralErrorCode.PAYMENT_CONFIRM_FAILED); + + Payment payment = paymentRepository.findByOrderId(prepared.orderId()).orElseThrow(); + assertThat(payment.getStatus()).isEqualTo(PaymentStatus.FAILED); + assertThat(userRepository.findById(user.getId()).orElseThrow().getCredit()).isEqualTo(1); + assertThat(creditTransactionRepository.findAllByUserIdAndTypeOrderByCreatedAtDescIdDesc( + user.getId(), + CreditTransactionType.CHARGE + )).isEmpty(); + } + @Test @DisplayName("동일 결제 승인 요청이 동시에 들어와도 한 번만 크레딧을 충전한다") void confirmConcurrentlyChargesOnlyOnce() throws Exception {