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
4 changes: 3 additions & 1 deletion docs/frontend-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,12 +252,14 @@ Refresh는 앱 전체에서 한 번씩 실행되도록 묶는다. StrictMode·
- 잔액은 available/reserved/total 및 보류 여부를 구분한다. 최초 온보딩 완료 시 기본3크레딧, 크레딧 무기한. 지급 내역이 없는 기존 완료 계정은0이며 자동 소급하지 않는다. 구매 취소와 생성 실패 예약 해제는 다른 처리다.
- 구매별 환불 접수와 조회는 [환불 접수 계약](payment-refund-intake.md)을 따른다. 페이앱 취소는 판매자 화면에서 진행하고, 확인된 취소 결과를 운영 명령으로 정산한다.

## 환불 접수와 정산 (#132, #134, #137)
## 환불 접수와 정산 (#132, #134, #137, #139)

환불 신청·상세·목록의 최상위 `orderId`로 결제 주문을 연결합니다. 접수가 종료된 뒤에도 같은 값이 제공되며, 개별 접수는 `receiptId`로 구분합니다.
환불 상세·목록의 `processingStatus`로 접수와 최종 처리 결과를 구분합니다.
`RECEIVED`는 `currentEstimate`를 표시하고 종료된 건은 `resolution`의 금액·회수 수량·처리 시각을 표시합니다.
주문 `REFUND_SETTLED`는 충전 폴링을 종료하고, 탈퇴 전에는 환불 접수번호와 문의 이메일을 저장할 수 있게 안내합니다.
주문과 환불 조회의 `confirmedCancellationAmount`는 확인이 끝난 주문 누적 취소액이며, 정산 전에는 `null`입니다.
접수 건의 `resolution.refundedAmount`는 최초 정산 결과로 유지됩니다. 이후 추가 통보가 재검토 중이면 환불 접수가 완료 상태여도 `orderState=PAYMENT_REVIEW`가 함께 반환됩니다.
[환불 접수 API](payment-refund-intake.md)와 [환불 상태 계약](payment-refund-settlement.md)을 참고합니다.

## 무료 크레딧 우선 차감 (#130)
Expand Down
42 changes: 42 additions & 0 deletions docs/payment-refund-settlement.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,45 @@
- V10을 CD로 적용한다. V9 접수와 원장은 보존된다. 정산이 열린 동안은 탈퇴 기록을 파기하지 않으며, 완료된 환불의 보관 기한에는 최종 처리 시각을 반영한다.
- 환불 접수 공개 전 대상 판매자 계정의 전액·부분취소, 정산 후 반환, 실제 취소 통보, 탈퇴 후 문의를 운영자와 검증한다. 현재 공개 플래그 기본값은 false다.
- 운영 정산이 시작되면 `REFUND_SETTLED`와 정산 기록을 읽는 버전으로 운영한다. 일시 중지는 신규 접수 플래그를 내려 처리한다.

## 정산 후 추가 취소 대조 (#139)

V11은 최초 정산과 증빙을 보존하고 추가 취소 확인 이력을 분리한다.
기존 확인 취소와 다른 확정 통보가 수신되어 보류된 완료 접수만 대상으로 한다.

1. 보류된 완료 접수를 조회한다. 한 번에 최대 20건이며 다음 페이지는 반환된
`nextAfterReceiptId`를 `--after-receipt-id`에 넣어 조회한다.

```bash
java -jar "$MONGLE_JAR" --payment-refund=list-follow-ups
java -jar "$MONGLE_JAR" --payment-refund=inspect-follow-up --receipt-id="$RECEIPT_ID"
```

2. 페이앱 판매자 화면에서 원주문과 추가 취소가 확정됐는지 대조한다.
조회 결과에서 최초 정산액, 이전 추가 확인 이력, 이미 확인한 누적액, 새 통보 금액을 구분한다.
새 통보 금액은 판단 전 참고값이다. 금액·귀속이 다르거나 통보가 충돌하면 보류한다.
3. 새로 확인한 금액만 `--amount`에 넣는다. 아래 예시는 최초 정산 900원, 추가 확정 취소 200원이다.
운영 참조번호는 같은 접수의 추가 대조 건마다 새로 부여한다.

```bash
java -jar "$MONGLE_JAR" --payment-refund=preview --receipt-id="$RECEIPT_ID" \
--action=follow-up --amount=200 --operator-reference=case-139-001
java -jar "$MONGLE_JAR" --payment-refund=apply --receipt-id="$RECEIPT_ID" \
--action=follow-up --amount=200 --operator-reference=case-139-001 \
--expected-snapshot="$REFUND_SNAPSHOT"
```

4. `READY`인 미리보기만 적용한다. 이후 통보나 상태가 달라졌으면 다시 대조한다.
적용 후 같은 주문만 `REFUND_SETTLED`로 돌아간다. 다른 주문의 보류가 남으면 계정의
`spendingBlocked`도 계속 true다.

추가 대조는 최초 접수의 `resolution.refundedAmount`와 크레딧 회수 원장을 변경하지 않는다.
새 확인 이력과 증빙 저장, 해당 보류 해제, 주문 상태 변경은 사용자 잠금 아래 함께 커밋한다.
같은 참조번호·금액·스냅샷으로 재실행하면 당시 처리 결과를 반환하며, 더 최근에 잡힌 보류에는 손대지 않는다.
한 주문에서 확정 통보의 거래 식별자가 32건 이상이면 증빙을 일부만 처리하지 않고 검토 상태를 유지한다.
추가 대조가 적용된 거래의 보존 기한에는 최종 확인 시각을 반영한다. 재검토 중에는 파기하지 않는다.

주문·환불 조회의 `confirmedCancellationAmount`는 최초 정산과 적용한 추가 취소를 합한 주문 단위 금액이다.
확인된 정산이 없으면 null이며, 새 통보가 보류 중이면 확인을 끝낸 금액까지만 포함한다.
환불 조회의 `orderState`가 `PAYMENT_REVIEW`여도 원래 접수의 `processingStatus=COMPLETED`는 유지된다.
V11을 적용한 릴리스의 운영 명령을 사용한다.
59 changes: 51 additions & 8 deletions scripts/payment_reclamation_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,17 @@ def verify(repo, jar, credit_env, sql):
owner = sql("INSERT INTO users(email,nickname,created_at,updated_at) VALUES('reclamation-cli@test.invalid','Reclaim',NOW(),NOW()); SELECT LAST_INSERT_ID();", "credit_test").strip()
sql(f"INSERT INTO credit_accounts(user_id,available,reserved,version,created_at,updated_at) VALUES({owner},0,0,0,NOW(),NOW());", "credit_test")

def cancelled():
def cancelled(partial_amount=None):
order = str(uuid.uuid4())
tx = str(uuid.uuid4().int % (10**30) + 1)
cancel_tx = str(uuid.uuid4().int % (10**30) + 1) if partial_amount else tx
cancel_state = 70 if partial_amount else 9
cancel_amount = partial_amount or 1100
partial_binding = (
f"INSERT INTO payment_transaction_bindings(transaction_id,order_id) "
f"SELECT '{cancel_tx}',id FROM payment_orders WHERE public_id='{order}';"
if partial_amount else ""
)
key = "payapp:" + order
digest = hashlib.sha256(key.encode()).hexdigest()
grant = sql(f"""INSERT INTO credit_grants(user_id,source_type,source_key,source_hash,amount,available,reserved,consumed,version,created_at)
Expand All @@ -31,8 +39,9 @@ def cancelled():
INSERT INTO payment_transaction_bindings(transaction_id,order_id) SELECT '{tx}',id FROM payment_orders WHERE public_id='{order}';
INSERT INTO payment_notifications(event_hash,order_id,provider_transaction,state_code,amount,responded_at,created_at)
SELECT SHA2('{uuid.uuid4()}',256),id,'{tx}',4,1100,UTC_TIMESTAMP()-INTERVAL 30 SECOND,NOW() FROM payment_orders WHERE public_id='{order}';
{partial_binding}
INSERT INTO payment_notifications(event_hash,order_id,provider_transaction,state_code,amount,responded_at,created_at)
SELECT SHA2('{uuid.uuid4()}',256),id,'{tx}',9,1100,UTC_TIMESTAMP(),NOW() FROM payment_orders WHERE public_id='{order}';
SELECT SHA2('{uuid.uuid4()}',256),id,'{cancel_tx}',{cancel_state},{cancel_amount},UTC_TIMESTAMP(),NOW() FROM payment_orders WHERE public_id='{order}';
INSERT INTO credit_payment_holds(grant_id,user_id,created_at) VALUES({grant},{owner},NOW());
INSERT INTO credit_ledger(user_id,entry_key,kind,source_type,grant_id,available_delta,reserved_delta,available_after,reserved_after,created_at)
SELECT {owner},'PAYMENT_HOLD:{grant}','PAYMENT_HOLD','PAYMENT',{grant},0,0,available,reserved,NOW() FROM credit_accounts WHERE user_id={owner};""", "credit_test")
Expand Down Expand Up @@ -91,7 +100,7 @@ def command(order, mode, key, token=None, expected_exit=0, overrides=None, no_ch
raise RuntimeError("Packaged reclamation final balance mismatch.")
print("Packaged JAR: reclaim preview SELECT-only/apply/replay/safe rejection/concurrent JVMs passed without Boot or provider calls.", flush=True)

order, grant = cancelled()
order, grant = cancelled(partial_amount=900)
verify_refund(repo, jar, credit_env, sql, order, grant)


Expand All @@ -105,11 +114,14 @@ def verify_refund(repo, jar, credit_env, sql, order, grant):
SELECT '{receipt}','{uuid.uuid4()}',user_id,id,{grant},'RIGHTS_REVIEW','REVIEW_REQUIRED',10,0,0,NOW()
FROM payment_orders WHERE public_id='{order}';""", "credit_test")

def command(mode, token=None, enabled=True, expected_exit=0):
args = ["java", "-Xmx128m", "-jar", str(jar), "--payment-refund=" + mode, "--receipt-id=" + receipt]
if mode != "inspect":
args.extend(["--action=settle", "--amount=1100", "--credits=10", "--basis=REVIEW",
"--operator-reference=fixture-134"])
def command(mode, token=None, enabled=True, expected_exit=0, follow_up=False):
args = ["java", "-Xmx128m", "-jar", str(jar), "--payment-refund=" + mode]
if mode != "list-follow-ups":
args.append("--receipt-id=" + receipt)
if mode in {"preview", "apply"}:
args.extend(["--action=follow-up", "--amount=200", "--operator-reference=fixture-139"] if follow_up
else ["--action=settle", "--amount=900", "--credits=10", "--basis=REVIEW",
"--operator-reference=fixture-134"])
if token:
args.append("--expected-snapshot=" + token)
outcome = subprocess.run(args, cwd=repo, env={
Expand All @@ -131,3 +143,34 @@ def command(mode, token=None, enabled=True, expected_exit=0):
if sql(f"SELECT available FROM credit_grants WHERE id={grant};", "credit_test").strip() != "0":
raise RuntimeError("Packaged refund did not reclaim the target grant.")
print("Packaged JAR: refund inspect/preview/flag and concurrent apply passed without Boot or provider calls.", flush=True)

extra_tx = str(uuid.uuid4().int % (10**30) + 1)
sql(f"""UPDATE payment_orders SET state='PAYMENT_REVIEW',version=version+1 WHERE public_id='{order}';
INSERT INTO payment_transaction_bindings(transaction_id,order_id)
SELECT '{extra_tx}',id FROM payment_orders WHERE public_id='{order}';
INSERT INTO payment_notifications(event_hash,order_id,provider_transaction,state_code,amount,responded_at,created_at)
SELECT SHA2('{uuid.uuid4()}',256),id,'{extra_tx}',71,200,UTC_TIMESTAMP(),NOW()
FROM payment_orders WHERE public_id='{order}';
INSERT INTO credit_payment_holds(grant_id,user_id,created_at)
SELECT {grant},user_id,NOW() FROM payment_orders WHERE public_id='{order}';
INSERT INTO credit_ledger(user_id,entry_key,kind,source_type,grant_id,
available_delta,reserved_delta,available_after,reserved_after,created_at)
SELECT a.user_id,'PAYMENT_HOLD:{grant}:1','PAYMENT_HOLD','PAYMENT',{grant},0,0,a.available,a.reserved,NOW()
FROM payment_orders o JOIN credit_accounts a ON a.user_id=o.user_id WHERE o.public_id='{order}';""", "credit_test")
sql("REVOKE INSERT,UPDATE,DELETE ON credit_test.* FROM 'mongle_app'@'127.0.0.1';")
try:
if command("list-follow-ups").get("items") != [{"receiptId": receipt, "orderId": order}]:
raise RuntimeError("Packaged follow-up discovery mismatch.")
if command("inspect-follow-up").get("unreviewedCancellationAmount") != 200:
raise RuntimeError("Packaged follow-up inspection mismatch.")
follow_up = command("preview", follow_up=True)
if follow_up.get("decision") != "READY":
raise RuntimeError("Packaged follow-up preview mismatch.")
finally:
sql("GRANT SELECT,INSERT,UPDATE,DELETE ON credit_test.* TO 'mongle_app'@'127.0.0.1';")
with ThreadPoolExecutor(max_workers=2) as pool:
outcomes = list(pool.map(lambda _: command("apply", follow_up["snapshot"], follow_up=True), range(2)))
if (outcomes[0] != outcomes[1] or outcomes[0].get("confirmedCancellationAmount") != 1100
or command("list-follow-ups").get("items") != []):
raise RuntimeError("Independent follow-up JVMs did not clear the target hold exactly once.")
print("Packaged JAR: follow-up discovery/inspection/preview SELECT-only and concurrent apply passed.", flush=True)
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,18 @@
@Schema(description="본인 주문. 결제창 URL 발급은 결제 완료가 아닙니다. PAID와 creditGranted를 확인하세요. CREDIT_RECLAIMED는 전액 승인취소된 미사용 구매분의 내부 회수 완료입니다. REFUND_SETTLED는 접수 건의 PG 취소와 내부 정산이 완료된 상태입니다. creditGranted는 과거 지급 연결 여부이며 현재 잔액이 아닙니다. UNKNOWN은 결과 미확정이며 같은 주문으로 결제를 자동 재요청하지 않습니다.")
public record PaymentOrderResponse(String orderId, String productId, String productName, long price,
String currency, long credits, PaymentOrder.State state, String paymentUrl, boolean creditGranted,
LocalDateTime createdAt) {
LocalDateTime createdAt,
@Schema(description = "환불 정산 및 이후 재대조에서 확인한 주문 누적 취소액. 확인된 정산이 없으면 null.")
Long confirmedCancellationAmount) {
static PaymentOrderResponse from(PaymentOrder o,Instant now) {
return from(o, now, null);
}

static PaymentOrderResponse from(PaymentOrder o,Instant now,Long confirmedCancellationAmount) {
var state=o.getState();
if(state==PaymentOrder.State.REQUESTING && !o.getRequestDeadline().isAfter(now)) state=PaymentOrder.State.UNKNOWN;
return new PaymentOrderResponse(o.getPublicId(),o.getProductId(),o.getProductName(),o.getPrice(),
o.getCurrency(),o.getCredits(),state,state==PaymentOrder.State.READY?o.getPaymentUrl():null,
o.getCreditGrantId()!=null,o.getCreatedAt());
o.getCreditGrantId()!=null,o.getCreatedAt(),confirmedCancellationAmount);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@
import com.mongle.backend.global.error.BusinessException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
import java.time.Clock;

@Service @RequiredArgsConstructor @Transactional(readOnly=true)
@Service @RequiredArgsConstructor @Transactional(readOnly=true, isolation=Isolation.REPEATABLE_READ)
public class PaymentQueryService {
private final PaymentOrderRepository orders;
private final UserRepository users;
private final PaymentRefundRepository refunds;
private final Clock authClock;
public PaymentOrderResponse byRequestId(Long user,String requestId) {
requireUser(user);
Expand All @@ -22,23 +24,29 @@ public PaymentOrderResponse byRequestId(Long user,String requestId) {
} catch(IllegalArgumentException | NullPointerException ex) {
throw new BusinessException(PaymentErrorCode.INVALID_INPUT);
}
return PaymentOrderResponse.from(orders.findByUserIdAndRequestId(user,canonical)
.orElseThrow(() -> new BusinessException(PaymentErrorCode.NOT_FOUND)),authClock.instant());
return response(orders.findByUserIdAndRequestId(user,canonical)
.orElseThrow(() -> new BusinessException(PaymentErrorCode.NOT_FOUND)));
}
public PaymentOrderResponse get(Long user,String publicId) {
requireUser(user);
return PaymentOrderResponse.from(orders.findByPublicIdAndUserId(publicId,user)
.orElseThrow(() -> new BusinessException(PaymentErrorCode.NOT_FOUND)),authClock.instant());
return response(orders.findByPublicIdAndUserId(publicId,user)
.orElseThrow(() -> new BusinessException(PaymentErrorCode.NOT_FOUND)));
}
public PaymentOrderPage page(Long user,Long beforeId,int size) {
if(size<1 || size>50 || beforeId!=null && beforeId<1) throw new BusinessException(PaymentErrorCode.INVALID_PAGE);
requireUser(user);
var rows=orders.page(user,beforeId,org.springframework.data.domain.PageRequest.of(0,size+1));
boolean hasNext=rows.size()>size;
var now=authClock.instant();
return new PaymentOrderPage(rows.stream().limit(size).map(o -> PaymentOrderResponse.from(o,now)).toList(),
var page = rows.subList(0, Math.min(size, rows.size()));
var totals = refunds.confirmedCancellationAmounts(page.stream().map(PaymentOrder::getId).toList());
return new PaymentOrderPage(page.stream().map(o -> PaymentOrderResponse.from(o,now,totals.get(o.getId()))).toList(),
hasNext,hasNext?rows.get(size-1).getId():null);
}

private PaymentOrderResponse response(PaymentOrder order) {
return PaymentOrderResponse.from(order, authClock.instant(), refunds.confirmedCancellationAmount(order.getId()));
}
private void requireUser(Long id) {
if(id==null || id<1) throw new BusinessException(PaymentErrorCode.INVALID_INPUT);
var u=users.findById(id).orElseThrow(() -> new BusinessException(PaymentErrorCode.NOT_FOUND));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ class PaymentRefundEvidence {
private Long id;
@Column(nullable = false)
private Long refundId;
private Long followUpId;
@Column(nullable = false, length = 40, unique = true)
private String providerTransaction;
@Column(nullable = false)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.mongle.backend.domain.payment;

import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;

import java.time.Instant;

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "payment_refund_follow_ups", uniqueConstraints = {
@UniqueConstraint(columnNames = {"refund_id", "operator_reference"}),
@UniqueConstraint(columnNames = {"id", "refund_id"})
})
class PaymentRefundFollowUp {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private Long refundId;
@Column(nullable = false)
private long additionalAmount;
@Column(nullable = false)
private long confirmedTotal;
@Column(nullable = false, length = 80)
private String operatorReference;
@Column(nullable = false, length = 64)
private String snapshotHash;
@JdbcTypeCode(SqlTypes.TIMESTAMP)
@Column(nullable = false)
private Instant confirmedAt;
}
Loading