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

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

환불 상세·목록의 `processingStatus`로 접수와 최종 처리 결과를 구분합니다.
`RECEIVED`는 `currentEstimate`를 표시하고 종료된 건은 `resolution`의 금액·회수 수량·처리 시각을 표시합니다.
주문 `REFUND_SETTLED`는 충전 폴링을 종료하고, 탈퇴 전에는 환불 접수번호와 문의 이메일을 저장할 수 있게 안내합니다.
[환불 접수 API](payment-refund-intake.md)와 [환불 상태 계약](payment-refund-settlement.md)을 참고합니다.

## 무료 크레딧 우선 차감 (#130)

새 이미지 생성/재생성 비용은 서버가 무료(SIGNUP·ATTENDANCE·PROMOTION)부터 예약하고 부족한 수량만 유료(PAYMENT)에서 예약합니다. 같은 그룹 내에서는 먼저 지급한 순서입니다. API 경로·요청·응답과 이미지 3/무료/1 비용은 바뀌지 않습니다.
Expand Down
5 changes: 3 additions & 2 deletions docs/payapp-payments.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
개인사업자 계정의 상품·실결제 승인은 팀에서 확인했으며 실제 청구·통보·정산 종단 검증은 별도다. 기본은 신규 결제/Provider/크레딧 지급 모두 비활성이다.
[실결제 설정·프론트 복구·운영 검증 안내](payapp-live-readiness.md)를 함께 확인한다.
유상/무료 크레딧 모두 무기한으로 사용하는 정책을 반영해 expiry-policy 기본은 NO_EXPIRY로 변경했다.
[구매별 환불 접수](payment-refund-intake.md)는 #132에서 추가했다. PG 환불 실행·실물 상품은 별도 범위다. 가입 지급과 이미지 과금은 별도 크레딧 기능을 재사용한다.
[구매별 환불 접수](payment-refund-intake.md)는 #132에서 추가했다. 환불 결과는 [운영 정산 명령](payment-refund-settlement.md)으로 대조한다. 가입 지급과 이미지 과금은 별도 크레딧 기능을 재사용한다.
신규 이미지 예약은 무료 지급분을 먼저 사용하고 부족한 수량만 구매 지급분에서 사용한다(#130). 같은 그룹 안에서는 지급 ID 순서를 유지하며 기존 예약·사용 원장은 소급 변경하지 않는다. 구매별 사용량은 계정 전체 잔액이 아니라 저장된 출처별 예약·확정 배분으로 확인한다. [크레딧 예약 계약](credits.md)을 따른다.

## 공개 계약
Expand Down Expand Up @@ -52,6 +52,7 @@ orderId는 UUID, createdAt은 기존 계약의 UTC LocalDateTime이다. 거래
| PAID | 검증된 결제 완료와 크레딧 지급 커밋 완료 |
| CANCELLED | 지급 전 취소 확인. 추가 지급하지 않음 |
| PAYMENT_REVIEW | 취소/순서 충돌 등의 결제 정합성 확인 필요. 자동 재지급하지 않음 |
| REFUND_SETTLED | 접수된 환불의 실제 취소 확인과 구매분 정산 완료 |
| CREDIT_RECLAIMED | 전액 승인취소된 미사용 구매분의 내부 회수 완료. 결제 성공이 아님. creditGranted는 과거 지급 연결 여부 |

creditGranted는 과거 지급이 존재하는지 나타내므로 state와 함께 확인한다.
Expand Down Expand Up @@ -135,7 +136,7 @@ mongle:

## 환불 기능과 정책의 구분

[환불 접수 API](payment-refund-intake.md)는 구매별 예상 금액과 신청을 기록하고 대상 구매분의 사용을 보류한다. 페이앱에서의 승인 취소, 취소 결과 확인 및 회수·보류 해제는 후속 정산 경로에서 처리한다. 결제사에서 먼저 취소 통보가 오면 기존 보류가 적용되고, 접수 내역의 현재 산정 상태는 검토 대상으로 바뀐다.
[환불 접수 API](payment-refund-intake.md)는 구매별 예상 금액과 신청을 기록하고 대상 구매분의 사용을 보류한다. 판매자 화면에서 승인 취소한 뒤 [정산 명령](payment-refund-settlement.md)으로 취소 결과 확인·회수·보류 해제를 처리한다. 결제사에서 먼저 취소 통보가 오면 기존 보류가 적용되고, 접수 내역의 현재 산정 상태는 검토 대상으로 바뀐다.

#64 이미지 생성 실패의 예약 해제는 구매 대금 환불과 다르다. 사용하지 않은 예약을 원래 잔액으로 돌려주며 이미 성공 확정한 생성 비용을 임의로 반환하지 않는다. 무기한 크레딧 정책을 환불 권리 포기로 해석하지 않는다.

Expand Down
4 changes: 2 additions & 2 deletions docs/payment-refund-intake.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,8 @@

1. 기존 CD로 `V9__payment_refund_intake.sql`을 적용한다. 기존 결제의 승인 시각과 환불 접수 건은 백필하지 않는다.
2. `MONGLE_PAYMENT_REFUND_ENABLED` 기본값은 false다. 활성화 시 공개 문의 주소 `MONGLE_PAYMENT_REFUND_SUPPORT_EMAIL`이 필요하다.
3. 실제 페이앱 취소와 크레딧 회수, 접수 종료·보류 해제 처리 경로를 준비한 뒤 공개한다. 이 배포의 상태는 접수 및 검토 대기다.
3. V10 적용 후 [환불 정산 운영 절차](payment-refund-settlement.md)를 검증하고 공개한다.
4. 신규 접수를 끌 때도 이미 접수된 건의 조회·보류·탈퇴 확인·보존은 계속 동작한다. 문의 주소는 유지한다. 접수가 생성된 운영 DB에서 V9 이전 앱은 새 구매분 보류를 읽을 수 없으므로 신규 접수를 중지할 때는 V9 앱의 설정을 사용한다.

운영자는 접수번호로 `payment_refund_requests`와 원주문·지급 원장을 대조한다.
서버의 예상액은 실제 카드 취소 증빙을 대신하지 않으므로 페이앱 거래 확인 후 후속 정산 경로에서 처리한다.
[환불 정산](payment-refund-settlement.md)으로 실제 페이앱 취소를 대조한 뒤 회수와 접수 종료를 처리한다.
71 changes: 71 additions & 0 deletions docs/payment-refund-settlement.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# 페이앱 환불 결과 대조와 정산 (#134)

판매자 계정에서 처리한 환불 결과를 몽글 내부 주문과 대조한 뒤 구매분을 회수한다.
명령은 애플리케이션 서버나 워커를 띄우지 않고, 지정한 MySQL에 검증 TLS로 접속한다.
페이앱 판매자 화면에서 전액·부분취소와 정산 후 반환 절차를 확인하는 단계가 선행된다.
[페이앱 결제 취소와 통보 명세](https://docs.payapp.kr/dev_center01.html)를 참고한다.

## 권한과 설정

운영 DB에 접근할 수 있는 지정 운영자만 새 JAR의 명령을 실행한다. 아래 변수를 해당 명령의 환경에 주입한다.

| 변수 | 값 |
| --- | --- |
| `MONGLE_PAYMENT_REFUND_OPS_ENABLED` | 명령 실행 시 true |
| `MONGLE_PAYMENT_REFUND_OPS_EXPECTED_HOST` | 지정 MySQL 호스트 |
| `MONGLE_PAYMENT_REFUND_OPS_EXPECTED_DATABASE` | 지정 DB 이름 |
| `DB_URL`, `DB_USERNAME`, `DB_PASSWORD` | 기존 검증 대상 URL과 `mongle_app` 계정 |
| `DB_TRUSTSTORE_URL`, `DB_TRUSTSTORE_PASSWORD` | 기존 PKCS12 신뢰 저장소 |

`inspect`와 `preview`는 읽기 전용이다. `apply`는 해당 사용자 행 잠금을 잡고 최신 상태를 검증한 뒤 한 번의 트랜잭션으로 처리한다.
운영 근거에는 승인자, 판매자 화면 확인 결과와 고객센터 케이스를 기록한다. 명령에는 해당 기록의 식별자만 넘긴다.

## 전액·부분취소 정산

1. 접수번호로 원주문, 구매액, 사용·예약·미사용 수량과 진행 상태를 확인한다.

```bash
java -jar "$MONGLE_JAR" --payment-refund=inspect --receipt-id="$RECEIPT_ID"
```

2. 페이앱 판매자 화면에서 주문 귀속과 취소 가능한 금액을 확인한다. 일반 환불은 `standardEstimatedAmount`를 사용한다. 법정 청약철회·서비스 하자·승인일 불명처럼 별도 판단이 필요하면 최종 승인 금액과 근거를 운영 기록에 남기고 `REVIEW`로 처리한다. 정산 후 반환 대기 또는 기존 취소 결과가 불명확하면 확인이 끝날 때까지 접수 상태를 유지한다.
3. 실제 취소를 처리한 뒤 금액과 회수할 크레딧을 각각 입력한다. 예시는 1,100원에 10개를 구매해 3개를 사용했고 일반 환불의 공제가 없는 경우다. 이미 예약한 수량은 이미지 처리 또는 탈퇴 해제로 확정한 뒤 진행한다.

```bash
java -jar "$MONGLE_JAR" --payment-refund=preview --receipt-id="$RECEIPT_ID" \
--action=settle --amount=770 --credits=7 --basis=STANDARD --operator-reference=case-134-001
```

4. 판매자 화면의 최종 취소와 `decision=READY`를 모두 확인하고, 같은 입력에 미리보기의 `snapshot`을 더해 실행한다.

```bash
java -jar "$MONGLE_JAR" --payment-refund=apply --receipt-id="$RECEIPT_ID" \
--action=settle --amount=770 --credits=7 --basis=STANDARD --operator-reference=case-134-001 \
--expected-snapshot="$REFUND_SNAPSHOT"
```

전액 취소(9/64)는 원거래 식별자·원결제액을 대조한다. 부분취소(70/71)는 콜백에서 검증한 원주문 귀속과 별도 취소 거래 식별자를 대조한다.
동일 취소 거래의 반복 통보는 한 번만 합산하고, 기존 취소를 포함한 합계는 승인 금액과 정확히 일치하며 원결제액 이하여야 한다.
원주문 결제완료 통보보다 앞선 취소, 취소 금액 충돌, 원거래와 같은 식별자의 부분취소 등은 확인 대상으로 남긴다.
여러 부분취소 뒤 전액취소 통보가 함께 들어와 합계가 맞지 않으면 판매자 화면에서 대조하고 보류를 유지한다.
정산 후 돌아온 동일 통보는 정산을 반복하지 않는다. 새 취소 증빙이 오면 해당 주문을 다시 검토 상태로 보내고 구매분 보류를 건다.

정산할 때 남은 해당 구매분 전량을 회수한다. 환불액이 공제 등으로 줄어도 수량은 원화에서 역산하지 않는다.
이미 전량 사용한 별도 검토 건은 회수 수량 0으로 실제 확인된 환불액만 기록할 수 있다.
완료 시 회수·원장·접수 종료·해당 결제 보류 해제를 함께 커밋한다. 다른 주문의 보류는 따로 유지한다.
같은 입력·스냅샷의 재실행은 기존 결과를 반환한다. `SNAPSHOT_CHANGED`이면 최신 결과를 확인한 뒤 미리보기부터 다시 진행한다.

## 반려와 철회

페이앱에서 취소 실행·취소 요청·정산 후 반환 대기가 없음을 확인한 뒤 `--action=reject` 또는 `--action=withdraw`를 사용한다.
`--amount`, `--credits`, `--basis` 없이 먼저 `preview`, 같은 인자와 스냅샷으로 `apply`를 실행한다.
취소 통보·외부 보류가 있거나 원주문이 결제 완료 상태가 아니면 접수 상태를 유지한다.
반려·철회 근거는 운영 참조 번호로 남긴다. 종료된 반려·철회는 보존하며, 다시 신청할 때 새 요청 UUID를 사용한다.

## 프론트와 운영 인계

- 환불 상세·목록의 `processingStatus`는 `RECEIVED`, `COMPLETED`, `REJECTED`, `WITHDRAWN`이다. 접수 중에는 `currentEstimate`, 종료 후에는 `resolution`의 환불 금액·회수량·완료 시각을 표시한다.
- 주문 상태 `REFUND_SETTLED`는 충전 확인 폴링을 끝내고 환불 완료를 표시한다. `PAYMENT_REVIEW`는 별도 확인이 필요한 상태다. 접수번호로 문의할 때 운영자는 신청자와 원주문을 대조한다.
- V10을 CD로 적용한다. V9 접수와 원장은 보존된다. 정산이 열린 동안은 탈퇴 기록을 파기하지 않으며, 완료된 환불의 보관 기한에는 최종 처리 시각을 반영한다.
- 환불 접수 공개 전 대상 판매자 계정의 전액·부분취소, 정산 후 반환, 실제 취소 통보, 탈퇴 후 문의를 운영자와 검증한다. 현재 공개 플래그 기본값은 false다.
- 운영 정산이 시작되면 `REFUND_SETTLED`와 정산 기록을 읽는 버전으로 운영한다. 일시 중지는 신규 접수 플래그를 내려 처리한다.
2 changes: 1 addition & 1 deletion docs/user-withdrawal.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ SIGNUP/ATTENDANCE/PROMOTION은 무료다. 지급 출처를 한 번에 SUM 집계
같은 사용자 잠금 아래 실제 DB 접수 내역이 모든 남은 유료 구매분(사용 가능분과 진행 중 예약분)을 커버하는지 확인한다.
접수 완료 후 탈퇴하면 진행 중인 이미지 예약은 기존 절차로 해제하고 유료분과 접수는 별도로 보존한다.
탈퇴 전 접수번호와 문의 이메일을 안내하며, 미완료 접수 건은 보존기간 종료 시에도 파기하지 않는다.
페이앱 승인 취소 실행과 취소 결과에 따른 정산은 후속 작업이다.
탈퇴 뒤에도 [정산 명령](payment-refund-settlement.md)으로 검증된 취소에 따른 회수와 종료를 처리한다.

## 삭제·보존 표

Expand Down
41 changes: 41 additions & 0 deletions scripts/payment_reclamation_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,44 @@ def command(order, mode, key, token=None, expected_exit=0, overrides=None, no_ch
if sql(f"SELECT available FROM credit_accounts WHERE user_id={owner};", "credit_test").strip() != "0":
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()
verify_refund(repo, jar, credit_env, sql, order, grant)


def verify_refund(repo, jar, credit_env, sql, order, grant):
env = {**credit_env, "MONGLE_PAYMENT_REFUND_OPS_ENABLED": "true",
"MONGLE_PAYMENT_REFUND_OPS_EXPECTED_HOST": "127.0.0.1",
"MONGLE_PAYMENT_REFUND_OPS_EXPECTED_DATABASE": "credit_test"}
receipt = str(uuid.uuid4())
sql(f"""INSERT INTO payment_refund_requests(receipt_id,request_id,user_id,order_id,grant_id,
reason,initial_status,available_at_request,reserved_at_request,used_at_request,created_at)
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"])
if token:
args.append("--expected-snapshot=" + token)
outcome = subprocess.run(args, cwd=repo, env={
**env, "MONGLE_PAYMENT_REFUND_OPS_ENABLED": str(enabled).lower()
}, capture_output=True, text=True, timeout=90)
if outcome.returncode != expected_exit or outcome.stderr.strip() or len(outcome.stdout.splitlines()) != 1:
raise RuntimeError("Packaged refund resolution failed; raw output suppressed.")
return json.loads(outcome.stdout)

if command("inspect", enabled=False, expected_exit=1).get("code") != "OPERATOR_COMMAND_DISABLED":
raise RuntimeError("Packaged refund command bypassed its operator flag.")
plan = command("preview")
if plan.get("decision") != "READY" or command("inspect").get("status") != "RECEIVED":
raise RuntimeError("Packaged refund preview mismatch.")
with ThreadPoolExecutor(max_workers=2) as pool:
outcomes = list(pool.map(lambda _: command("apply", plan["snapshot"]), range(2)))
if outcomes[0] != outcomes[1] or outcomes[0].get("status") != "COMPLETED":
raise RuntimeError("Packaged refund settlement was not idempotent.")
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)
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,18 @@
import com.mongle.backend.operations.credit.TestCreditCommand;
import com.mongle.backend.operations.payment.PaymentInspectCommand;
import com.mongle.backend.operations.payment.PaymentReclaimCommand;
import com.mongle.backend.operations.payment.PaymentRefundCommand;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class MongleBackendApplication {

public static void main(String[] args) {
if (PaymentRefundCommand.requested(args)) {
System.exit(PaymentRefundCommand.run(args, System.getenv(), System.out));
return;
}
if (PaymentReclaimCommand.requested(args)) {
System.exit(PaymentReclaimCommand.run(args, System.getenv(), System.out));
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ public interface CreditGrantRepository extends JpaRepository<CreditGrant, Long>
Optional<CreditGrant> findBySourceTypeAndSourceHash(CreditSource type, String hash);
String SPENDABLE_GRANTS = """
from credit_grants g where g.user_id = :userId and g.available > 0
and not exists (select 1 from payment_refund_requests r where r.grant_id = g.id)
and not exists (select 1 from payment_refund_requests r
left join payment_refund_resolutions s on s.refund_id = r.id
where r.grant_id = g.id and s.refund_id is null)
""";

@Query(value = "select coalesce(sum(g.available), 0) " + SPENDABLE_GRANTS, nativeQuery = true)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ public enum Kind { GRANT, RESERVE, SETTLE, RELEASE, PAYMENT_HOLD, RECLAIM, FORFE
@Column(name = "available_after", nullable = false) private long availableAfter;
@Column(name = "reserved_after", nullable = false) private long reservedAfter;

static CreditLedger paymentHold(CreditAccount account, Long grantId, long previousHolds) {
var entry = entry(account, Kind.PAYMENT_HOLD, grantId, null, CreditSource.PAYMENT, null, 0, 0);
if (previousHolds > 0) entry.entryKey += ":" + (previousHolds + 1);
return entry;
}

static CreditLedger entry(CreditAccount account, Kind kind, Long grantId, Long reservationId,
CreditSource source, CreditUsage usage, long availableDelta, long reservedDelta) {
var l = new CreditLedger(); l.userId = account.getUserId(); l.kind = kind;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
import org.springframework.data.domain.Pageable;
import java.util.List;
public interface CreditLedgerRepository extends JpaRepository<CreditLedger, Long> {
long countByGrantIdAndKind(Long grantId, CreditLedger.Kind kind);

@Query("select l from CreditLedger l where l.userId=:userId and (:beforeId is null or l.id<:beforeId) order by l.id desc")
List<CreditLedger> page(@Param("userId") Long userId, @Param("beforeId") Long beforeId, Pageable page);
}
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,8 @@ public void holdPayment(Long userId,String sourceKey) {
throw new BusinessException(CreditErrorCode.REQUEST_CONFLICT);
if(paymentHolds.existsById(grant.getId())) return;
paymentHolds.saveAndFlush(CreditPaymentHold.create(grant.getId(),userId));
ledger.saveAndFlush(CreditLedger.entry(account(userId),CreditLedger.Kind.PAYMENT_HOLD,grant.getId(),null,CreditSource.PAYMENT,null,0,0));
ledger.saveAndFlush(CreditLedger.paymentHold(account(userId), grant.getId(),
ledger.countByGrantIdAndKind(grant.getId(), CreditLedger.Kind.PAYMENT_HOLD)));
}
void requireSpendingAllowed(Long userId) {
if(paymentHolds.existsByUserId(userId)) throw new BusinessException(CreditErrorCode.PAYMENT_REVIEW);
Expand Down
Loading