Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
@@ -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 $$;
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
)
}
)
Comment on lines +13 to +21

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Look for schema migrations or ddl-auto config that creates/enforces
# the credit_transactions reference_id mapping and composite uniqueness.

echo "Migration/config files:"
fd -i '(^V[0-9].*|.*changelog.*|.*schema.*|.*migration.*|application.*\.(yml|yaml|properties))$'

echo
echo "Relevant schema/config references:"
rg -n -C3 'uk_credit_transactions_user_type_reference|credit_transactions|reference_id|ddl-auto|hibernate\.hbm2ddl'

Repository: JobDri-Developer/BackEnd

Length of output: 3867


Create an explicit migration to enforce the unique constraint and backfill existing duplicates.

The idempotency constraint (user_id, type, reference_id) is defined only in JPA metadata at lines 13-21. No explicit Flyway/Liquibase migration exists in ops/db/migrations/ and the constraint is absent from schema.sql. When deployed to production, ddl-auto: update cannot reliably create or enforce complex constraints, and existing duplicate rows will cause the constraint application to fail.

Create a migration file to:

  1. Identify and deduplicate existing credit_transactions rows by (user_id, type, reference_id)
  2. Add the unique constraint explicitly via ALTER TABLE
  3. Ensure pre-deployment validation that no duplicates remain

Also applies to: 45-46

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/jobdri/jobdri_api/domain/payment/entity/CreditTransaction.java`
around lines 13 - 21, The unique constraint defined in the CreditTransaction
entity's `@UniqueConstraint` annotation
(uk_credit_transactions_user_type_reference on user_id, type, and reference_id)
exists only in JPA metadata and is not enforced in the actual database schema.
Create a Flyway or Liquibase migration file in ops/db/migrations/ that first
identifies and removes any existing duplicate rows in the credit_transactions
table that violate the (user_id, type, reference_id) uniqueness requirement,
then explicitly adds the unique constraint using ALTER TABLE to enforce it at
the database level, ensuring the constraint definition matches what is declared
in the CreditTransaction entity's `@UniqueConstraint` annotation, and includes
pre-migration validation to confirm no duplicates remain before applying the
constraint.

public class CreditTransaction extends CreatedAtEntity {

@Id
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
@Getter
public enum PaymentStatus {
PENDING,
PROCESSING,
FAILED,
COMPLETED
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;
import java.util.Optional;

public interface CreditTransactionRepository extends JpaRepository<CreditTransaction, Long> {
List<CreditTransaction> findAllByUserIdOrderByCreatedAtDescIdDesc(Long userId);
List<CreditTransaction> findAllByUserIdAndTypeOrderByCreatedAtDescIdDesc(Long userId, CreditTransactionType type);
Optional<CreditTransaction> findByUserIdAndTypeAndReferenceId(Long userId, CreditTransactionType type, String referenceId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
Comment on lines +41 to +50

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reject same referenceId replays with a different amount.

Line 44 returns the old balance for any existing (user, type, referenceId), even when the retry carries a different amount. Compare the signed transaction amount before treating the call as idempotent.

Suggested fix
+        int transactionAmount = resolveTransactionAmount(type, amount);
         CreditTransaction existingTransaction = creditTransactionRepository
                 .findByUserIdAndTypeAndReferenceId(managedUser.getId(), type, referenceId)
                 .orElse(null);
         if (existingTransaction != null) {
+            if (existingTransaction.getAmount() != transactionAmount) {
+                throw new GeneralException(
+                        GeneralErrorCode.INVALID_PARAMETER,
+                        "referenceId가 기존 거래 금액과 일치하지 않습니다."
+                );
+            }
             return existingTransaction.getBalanceAfter();
         }
 
-        int transactionAmount = resolveTransactionAmount(type, amount);
         applyCreditChange(managedUser, type, amount);
         saveTransaction(managedUser, type, transactionAmount, description, referenceId);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/jobdri/jobdri_api/domain/payment/service/CreditService.java`
around lines 41 - 50, The code currently treats a transaction as idempotent
based solely on matching (user, type, referenceId), but does not verify that the
amount in a retry request matches the original transaction amount. To fix this,
after retrieving the existingTransaction, calculate the transactionAmount for
the current request using resolveTransactionAmount, then compare this amount
with the existing transaction's amount before returning the old balance. If the
amounts do not match, reject the request by throwing an appropriate exception to
prevent amount mismatches from being silently ignored.

return managedUser.getCredit();
}

Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Comment on lines +80 to +86

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Inspect Toss client failure taxonomy and confirm whether
# PaymentService can distinguish definitive payment rejection from unknown outcomes.

echo "Toss payment client definitions/usages:"
rg -n -C4 'class TossPaymentClient|interface TossPaymentClient|confirm\s*\('

echo
echo "Payment confirmation exception/status handling:"
rg -n -C4 'PAYMENT_CONFIRM_FAILED|PAYMENT_ALREADY_PROCESSED|RestClientException|WebClientResponseException|HttpStatus|RuntimeException'

Repository: JobDri-Developer/BackEnd

Length of output: 50380


Do not mark payments FAILED for unknown gateway outcomes.

TossPaymentClient.confirm() catches both HttpStatusCodeException (HTTP error responses) and RestClientException (timeouts, connection failures) and wraps both into GeneralException(GeneralErrorCode.PAYMENT_CONFIRM_FAILED) with no distinction. PaymentService.confirm() then treats all RuntimeExceptions identically, calling failConfirmation() to mark the payment FAILED before rethrowing.

This conflates definitive rejections with transient failures. If Toss approves but the response times out or the client fails to receive it, the payment becomes FAILED locally and unrecoverable.

Distinguish transient/unknown outcomes from definitive rejections in TossPaymentClient (e.g., map 4xx Toss responses to a specific exception type for true declines, leave transport failures as unknown). Only call failConfirmation() for definitive rejections; unknown outcomes should remain in PROCESSING for later reconciliation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentService.java`
around lines 80 - 86, The TossPaymentClient.confirm() method currently wraps
both HTTP errors and transient failures (timeouts, connection issues) into a
single GeneralException, causing PaymentService.confirm() to treat all
exceptions identically and mark payments as FAILED. To fix this, modify
TossPaymentClient.confirm() to distinguish between definitive rejections (4xx
HTTP responses) and transient failures (connection timeouts, network errors) by
throwing different exception types for each case. Then in
PaymentService.confirm(), update the catch block to only call
paymentTransactionService.failConfirmation() when catching the exception type
that represents definitive rejections, allowing transient failures to remain
unhandled so the payment stays in PROCESSING state for later reconciliation.

}
return paymentTransactionService.completeConfirmation(validatedUser.getId(), request);
}

public CreditBalanceResponse getBalance(User user) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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, "이미 처리된 결제입니다.");
}
Comment on lines +35 to +37

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Make same-key PROCESSING confirmations recoverable.

PaymentService.confirm commits PROCESSING before the Toss call; if the process dies or completion fails afterward, Line 35 rejects every retry and leaves the payment stuck without credit. Treat the same paymentKey as resumable, or add a lease/reconciliation path.

One possible local recovery path
-        if (payment.getStatus() == PaymentStatus.PROCESSING || payment.getStatus() == PaymentStatus.FAILED) {
+        if (payment.getStatus() == PaymentStatus.PROCESSING) {
+            if (!payment.hasPaymentKey(request.paymentKey())) {
+                throw new GeneralException(GeneralErrorCode.PAYMENT_ALREADY_PROCESSED, "이미 처리된 결제입니다.");
+            }
+            return new PaymentConfirmationStart(payment, false);
+        }
+        if (payment.getStatus() == PaymentStatus.FAILED) {
             throw new GeneralException(GeneralErrorCode.PAYMENT_ALREADY_PROCESSED, "이미 처리된 결제입니다.");
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentTransactionService.java`
around lines 35 - 37, The validation in PaymentTransactionService.confirm method
currently rejects all retries for payments in PROCESSING status by throwing an
exception on line 35, which prevents recovery if the confirmation process fails
after the status is set to PROCESSING. Modify the validation logic to allow
retries for the same paymentKey to be resumable; instead of unconditionally
throwing an exception for PROCESSING status, check if the incoming paymentKey
matches an existing PROCESSING payment and allow it to proceed as a resumable
operation rather than rejecting it as an error condition.


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) {
}
}
Loading
Loading