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
2 changes: 1 addition & 1 deletion docs/credits.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ expiresAt은 해당 생성 시도의 lease 기한이다. DB DATETIME(6) 정밀

유상/무료 모두 무기한 정책으로 확정해 기본값은 NO_EXPIRY다. 이전 UNDECIDED를 명시한 운영 설정은 활성화 전 수정해야 한다. 상품 유효기간과 예약 lease는 다른 값이며, 생성 시도의 예약 기한/복구는 유지한다.

이미지 사용량은 설정된 경우 양수여야 하고, 가입 지급량 설정은 음수일 수 없으며 신규 온보딩 지급에는 양수가 필요하다(기본3, 0/null은503). 미설정 기능은503으로 실패한다. 운영 비용 환경변수를 지정하면 기본 비용을 덮어쓴다. 무료 1회는 고정 재생성 단가를 0으로 설정하는 방식이 아니며 별도 이력으로 처리한다. 새 사용을 비활성화해도 기존 예약의 확정·실패 해제·만료 복구는 계속 가능하다. 결제 상품 가격은 페이앱 상품 설정으로 관리한다. #62의 외부 취소 통보는 PAYMENT_HOLD 원장/계정 사용 보류로 보호한다. 구매 환불 신청·자동 회수/보류 해제는 구현하지 않았다.
이미지 사용량은 설정된 경우 양수여야 하고, 가입 지급량 설정은 음수일 수 없으며 신규 온보딩 지급에는 양수가 필요하다(기본3, 0/null은503). 미설정 기능은503으로 실패한다. 운영 비용 환경변수를 지정하면 기본 비용을 덮어쓴다. 무료 1회는 고정 재생성 단가를 0으로 설정하는 방식이 아니며 별도 이력으로 처리한다. 새 사용을 비활성화해도 기존 예약의 확정·실패 해제·만료 복구는 계속 가능하다. 결제 상품 가격은 페이앱 상품 설정으로 관리한다. #62의 외부 취소 통보는 PAYMENT_HOLD 원장/계정 사용 보류로 보호한다. [구매별 환불 접수](payment-refund-intake.md)는 해당 지급분만 새 이미지 예약에서 제외한다. PG 환불 실행과 정산·보류 해제는 후속 범위다.

## 오류와 DB 적용

Expand Down
6 changes: 3 additions & 3 deletions docs/frontend-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ Refresh는 앱 전체에서 한 번씩 실행되도록 묶는다. StrictMode·
- PAYMENT_REVIEW 또는 잔액 `spendingBlocked=true`이면 구매/사용을 막고 확인 안내를 표시한다.
- `POST /api/v1/payments/payapp/feedback`은 페이앱 서버 전용 FORM 통보이며 프론트에서 호출하지 않는다.
- 잔액은 available/reserved/total 및 보류 여부를 구분한다. 최초 온보딩 완료 시 기본3크레딧, 크레딧 무기한. 지급 내역이 없는 기존 완료 계정은0이며 자동 소급하지 않는다. 구매 취소와 생성 실패 예약 해제는 다른 처리다.
- 구매 환불 신청·자동 회수/보류 해제 API는 아직 없다. 법정 환불 접수·운영 취소 처리 경로는 별도 준비 중이며 일괄 환불 불가를 확정한 것은 아니다.
- 구매별 환불 접수와 조회는 [환불 접수 계약](payment-refund-intake.md)을 따른다. 페이앱 환불 실행과 정산은 후속 범위다.

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

Expand Down Expand Up @@ -280,7 +280,7 @@ onboardingCompleted는 기존 닉네임 온보딩 상태로 유지합니다. 기
- `USER_409_3`: 실제 결제 승인 여부 확인 필요. 단순 결제창 진입만으로는 제한하지 않습니다.
- 잔액 조회에는 paidAvailable/paidReserved/freeAvailable/freeReserved가 추가됐으며 기존 잔액 필드는 유지합니다.
- 예약분과 유료 크레딧 수량은 환불 가능 금액을 뜻하지 않습니다. 프론트가 환불액·접수 여부를 계산하거나 탈퇴 요청에 전달하지 않습니다.
- 환불 접수 기능은 후속 이슈입니다. 서버의 기본 접수 판정은 유료 잔액 탈퇴를 차단하며 유료 출시는 환불 연동 완료 이후입니다.
- 남은 유료 구매분을 모두 [환불 접수](payment-refund-intake.md)한 뒤 탈퇴를 재시도합니다. 탈퇴 전에 접수번호와 문의 이메일을 저장할 수 있도록 안내합니다.
- 재가입은 허용하지만 이전 데이터·잔액을 복원하지 않고 같은 카카오 계정에 최초 가입 보상을 다시 지급하지 않습니다.

탈퇴는 복구 불가능하며 이미지 파일·카카오 연결은 후속 정리될 수 있습니다.
Expand All @@ -292,7 +292,7 @@ onboardingCompleted는 기존 닉네임 온보딩 상태로 유지합니다. 기
| 항목 | 현재 상태 |
|---|---|
| 이미지 크레딧 과금 | 최초3·첫 재생성 성공 무료·이후1 구현. 가격 미설정 시 무료 요청도 거절하며 실제 운영 활성화는 별도 |
| 판매 상품·기간 이용권·환불 | 판매 조건·상품 설정 확정과 실결제 검증 별도. 기간 이용권·환불 신청 API 없음 |
| 판매 상품·기간 이용권·환불 | 실결제 운영 검증, 기간 이용권 및 PG 환불 실행은 별도. 유료분 환불 접수는 #132로 추가 |
| 스타일4개 고정 카탈로그·프롬프트 | #86 구현·머지. 키·표시 이름은 docs/image-generation-policy.md 참고, 실제 생성 품질 검증 별도 |
| 세계관 전체 이미지 | #77 구현·통합. 정확한 SQL 적용과 운영 활성화·실검증은 별도 |
| 무료 종합 해석 생성/저장/갱신 | #84 구현. 기본 비활성, LINER 실검증 및 프론트 연동 필요 |
Expand Down
4 changes: 2 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로 변경했다.
결제 모듈은 구매 환불 신청·PG 자동 환불·실물 상품을 포함하지 않는다. 가입 지급과 이미지 과금은 별도 크레딧 기능을 재사용한다.
[구매별 환불 접수](payment-refund-intake.md)는 #132에서 추가했다. PG 환불 실행·실물 상품은 별도 범위다. 가입 지급과 이미지 과금은 별도 크레딧 기능을 재사용한다.
신규 이미지 예약은 무료 지급분을 먼저 사용하고 부족한 수량만 구매 지급분에서 사용한다(#130). 같은 그룹 안에서는 지급 ID 순서를 유지하며 기존 예약·사용 원장은 소급 변경하지 않는다. 구매별 사용량은 계정 전체 잔액이 아니라 저장된 출처별 예약·확정 배분으로 확인한다. [크레딧 예약 계약](credits.md)을 따른다.

## 공개 계약
Expand Down Expand Up @@ -135,7 +135,7 @@ mongle:

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

서비스 내 구매 환불 신청·자동 회수/보류 해제 API는 아직 없다. 이는 미사용 유료 크레딧을 일괄 환불 불가로 정한 정책이 아니다. 법정 청약철회·환불을 접수하고 실제 거래와 지급 출처·예약·사용량을 대조해 처리하는 별도 운영 경로가 필요하다. 페이앱 관리자 취소만으로 몽글 크레딧 회수가 끝나지는 않는다. 외부 취소 수신 시 현재 구현은 사용 보류를 설정한다.
[환불 접수 API](payment-refund-intake.md)는 구매별 예상 금액과 신청을 기록하고 대상 구매분의 사용을 보류한다. 페이앱에서의 승인 취소, 취소 결과 확인 및 회수·보류 해제는 후속 정산 경로에서 처리한다. 결제사에서 먼저 취소 통보가 오면 기존 보류가 적용되고, 접수 내역의 현재 산정 상태는 검토 대상으로 바뀐다.

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

Expand Down
2 changes: 1 addition & 1 deletion docs/payment-reclamation-command.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ sudo /usr/bin/systemd-run --quiet --wait --pipe --collect \
- REQUEST_CONFLICT / RECLAMATION_WRITE_CONFLICT: 키·기존 처리·동시 쓰기 충돌. 새 키로 우회하지 않는다.
- RECLAMATION_RECORD_INCONSISTENT / POSTCONDITION_FAILED: 기존 이력 또는 적용 후 검증 불일치. 새 변경은 롤백하며 운영 검토로 넘긴다.

부분 사용·부분취소·실제 환불 신청/PG 취소 API·임의 보류 해제는 후속 범위다. 모든 보류를 한꺼번에 지우는 명령은 제공하지 않는다.
환불 신청이 접수된 주문은 REFUND_INTAKE_EXISTS로 거절한다. 부분 사용·부분취소·PG 취소 및 접수 건 정산은 별도 범위다. 모든 보류를 한꺼번에 지우는 명령은 제공하지 않는다.

## 프론트 반영 사항

Expand Down
67 changes: 67 additions & 0 deletions docs/payment-refund-intake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# 유료 크레딧 환불 접수 (#132)

## 프론트 연동

모든 경로는 온보딩을 완료한 회원의 Bearer 인증과 기존 `ApiResponse`를 사용한다. 응답에는 `Cache-Control: no-store`를 설정한다. 최신 약관 동의가 필요한 기존 회원도 환불 경로를 이용할 수 있다.

| API | 사용 |
| --- | --- |
| `GET /api/v1/payments/refunds/estimate?orderId={UUID}&reason=STANDARD` | 본인 주문의 구매·사용·예약·미사용 수량과 예상 원화 금액 확인 |
| `POST /api/v1/payments/refunds` | 해당 주문의 남은 유료분에 대한 접수 및 보류 |
| `GET /api/v1/payments/refunds/{receiptId}` | 접수 당시 산정 근거와 현재 산정 상태 확인 |
| `GET /api/v1/payments/refunds?beforeId=&size=20` | 내 접수 목록. 크기 1~50, `hasNext`이면 `nextBeforeId`를 다음 요청에 사용 |

요청 예시

```json
{
"requestId": "6bf6a5ea-22a6-43ea-88dd-28780b42a3a1",
"orderId": "5f5654b2-716c-4a25-944a-ae512ccf434b",
"reason": "STANDARD"
}
```

`orderId`는 결제 API의 공개 주문 UUID다. `requestId`는 버튼을 누를 때 생성하고 같은 요청 재전송에 재사용한다.
일반 환불은 `STANDARD`, 청약철회·서비스 하자 등 판단이 필요한 신청은 `RIGHTS_REVIEW`를 사용한다.
금액·수량·결제사 번호는 클라이언트가 제출하지 않는다. 주문마다 접수 하나가 생성되며 동일 요청은 같은 `receiptId`로 응답한다.

접수 후 `receiptId`, `receivedAt`, `supportEmail`을 사용자에게 보여주고 저장할 수 있게 한다.
`initialStatus`와 `*AtRequest` 필드는 접수 당시 기록이다. `currentEstimate`는 예약 확정·해제나 결제 상태가 바뀐 뒤 다시 계산한 값이며, 접수 당시 시각을 기준으로 수수료 경계를 판정한다. 표시 문구는 ‘환불 신청 접수’로 하고, 예상액이 있는 경우에도 완료 또는 확정 금액으로 표시하지 않는다.

| 산정 상태 | 화면 처리 |
| --- | --- |
| `ESTIMATED` | 예상 금액과 공제율 표시 |
| `WAITING_RESERVATION` | 진행 중인 이미지 사용량을 확인한 뒤 예상액 갱신. 접수는 가능 |
| `REVIEW_REQUIRED` | 승인 시각 미확인, 별도 권리 검토 또는 기존 결제 취소 확인. 금액은 null |
| `NO_REFUNDABLE_BALANCE` | 일반 환불에 사용할 미사용분 없음. 권리 검토 접수는 별도 안내 |

| 오류 | 처리 |
| --- | --- |
| `REFUND_404_1` | 없는 주문·접수 또는 타인 소유 |
| `REFUND_409_1` | 이미 접수된 주문 또는 요청 UUID 충돌. 접수 목록 확인 |
| `REFUND_409_2` | 주문·지급 내역이 신청 조건을 충족하지 않음 |
| `REFUND_503_1` | 접수 기능 준비 전 |
| 400 | 요청 형식·페이지 값 확인 |

크레딧 잔액 API는 원장 합계를 반환한다. 환불 접수된 구매분의 미사용량은 위 접수 응답에서 확인한다.
보류분은 새 이미지 예약에서 빠지므로 원장 잔액만 충분하고 실제 차감 가능한 금액이 부족하면 `CREDIT_409_1`을 반환한다.
기존의 결제 취소 확인 보류는 `CREDIT_409_5`로 처리된다.

## 산정과 탈퇴

- 일반 예상액은 원주문 결제액 × (미사용 수량 ÷ 구매 수량)이다. 검증한 승인 시각부터 정확히 168시간까지 공제율은 0%, 그 이후 일반 환불 공제율은 10%다. 공제를 포함한 전체 산식의 결과를 마지막에 한 번 원 단위로 올림한다.
- [페이앱 결제통보 문서](https://docs.payapp.kr/dev_center01.html)의 `pay_date`를 KST 기준으로 해석해 신규 지급의 최초 승인 시각에 저장한다. 지원 형식은 `yyyyMMddHHmmss`, `yyyy-MM-dd HH:mm:ss`다. 저장 시 초 단위 반올림에 따른 최대 1초의 차이를 허용한다. 이 범위를 벗어나 주문 생성보다 이르거나 통보 시각보다 늦은 값, 누락·파싱 실패와 기존 주문의 승인 시각은 검토 대상으로 남긴다. `responsedate`는 기존 통보 인증·유효성 확인에 사용한다.
- 실제 지급과 동일한 사용자 행 잠금에서 주문·구매 지급분·예약·소유자를 다시 확인한다. 환불 신청은 기존 결제 취소 보류와 별개이며, 해당 구매분의 차감만 막는다. 기존 이미지 예약의 성공·실패 처리는 계속 가능하다.
- 모든 남은 유료 구매분이 접수 범위에 포함되면 탈퇴를 진행한다. 기존 탈퇴 처리에서 진행 중 예약을 해제하므로 접수된 유료분은 그 범위 내에서 다시 미사용분으로 돌아간다. 접수 이후 추가로 지급된 다른 구매분이 있으면 그 구매분도 확인한다.
- 탈퇴 후 로그인 세션은 해제된다. 사용자는 탈퇴 전에 저장한 접수번호로 고객센터에 문의한다. 접수번호는 검색 식별자이므로 고객센터는 거래와 신청자 확인을 거쳐 상태를 안내한다.
- 미완료 환불은 거래 보존기간이 지났어도 파기하지 않는다. 페이앱에서 이미 취소된 주문을 회수하는 기존 명령도 접수된 건을 거절하므로 동일 구매분을 이중 처리하지 않는다.

## 배포와 공개

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

운영자는 접수번호로 `payment_refund_requests`와 원주문·지급 원장을 대조한다.
서버의 예상액은 실제 카드 취소 증빙을 대신하지 않으므로 페이앱 거래 확인 후 후속 정산 경로에서 처리한다.
12 changes: 5 additions & 7 deletions docs/user-withdrawal.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,11 @@ SIGNUP/ATTENDANCE/PROMOTION은 무료다. 지급 출처를 한 번에 SUM 집계

## 범위와 후속 환불 기능

회원탈퇴 기능은 기본 비활성이다. 환불 신청 UI/API, 금액 계산, PG 환불 실행, 안내 경로는 후속 이슈다.
현재 기본 WithdrawalRefundPolicy는 유료 잔액을 승인하지 않는다.
후속 환불 서비스는 이 포트를 대체해 같은 사용자 잠금 안에서 실제 DB 접수 내역이
모든 남은 유료 구매분을 커버하는지 확인해야 한다. 클라이언트의 boolean이나
이메일 발송만으로 접수됐다고 처리하면 안 된다.
hasOpenRefunds는 보존기간 종료 시 미완료 접수 건의 삭제를 막아야 한다.
이 연결 전에는 유료 기능을 출시하지 않는다.
회원탈퇴와 환불 접수는 기본 비활성이다. [환불 접수 계약](payment-refund-intake.md)에 따라
같은 사용자 잠금 아래 실제 DB 접수 내역이 모든 남은 유료 구매분(사용 가능분과 진행 중 예약분)을 커버하는지 확인한다.
접수 완료 후 탈퇴하면 진행 중인 이미지 예약은 기존 절차로 해제하고 유료분과 접수는 별도로 보존한다.
탈퇴 전 접수번호와 문의 이메일을 안내하며, 미완료 접수 건은 보존기간 종료 시에도 파기하지 않는다.
페이앱 승인 취소 실행과 취소 결과에 따른 정산은 후속 작업이다.

## 삭제·보존 표

Expand Down
2 changes: 1 addition & 1 deletion scripts/mysql84-test.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ def stop_on_signal(signum, frame):
migration_password = secrets.token_hex(24)
sql(f"CREATE USER 'mongle_migrator'@'127.0.0.1' IDENTIFIED BY '{migration_password}' REQUIRE SSL;")
migration_databases = ("flyway_fresh", "flyway_existing", "flyway_drift", "flyway_failed",
"flyway_checksum", "flyway_privilege", "flyway_jpa", "flyway_v1", "flyway_cli", "flyway_check", "flyway_summary", "flyway_reclamation", "flyway_legal")
"flyway_checksum", "flyway_privilege", "flyway_jpa", "flyway_v1", "flyway_cli", "flyway_check", "flyway_summary", "flyway_reclamation", "flyway_legal", "flyway_refund")
schema = (repo / "src/main/resources/db/schema-initial.sql").read_text()
for database in migration_databases:
sql(f"CREATE DATABASE {database} CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
package com.mongle.backend.domain.credit;
import org.springframework.data.jpa.repository.JpaRepository;
public interface CreditAccountRepository extends JpaRepository<CreditAccount, Long> {}
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

public interface CreditAccountRepository extends JpaRepository<CreditAccount, Long> {
@Modifying
@Query("update CreditAccount a set a.version = a.version + 1 where a.userId = :user")
int invalidateSpendingSnapshot(@Param("user") Long userId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,16 @@
public interface CreditGrantRepository extends JpaRepository<CreditGrant, Long> {
Optional<CreditGrant> findFirstByUserIdAndSourceTypeOrderByIdAsc(Long userId, CreditSource sourceType);
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)
""";

@Query(value = "select coalesce(sum(g.available), 0) " + SPENDABLE_GRANTS, nativeQuery = true)
long spendable(@Param("userId") Long userId);

// Apply priority before pagination; retain grant order within free and paid groups.
@Query("select g from CreditGrant g where g.userId=:userId and g.available>0 "
+ "order by case when g.sourceType=com.mongle.backend.domain.credit.CreditSource.PAYMENT then 1 else 0 end,g.id")
@Query(value = "select g.* " + SPENDABLE_GRANTS
+ "order by case when g.source_type = 'PAYMENT' then 1 else 0 end, g.id", nativeQuery = true)
List<CreditGrant> available(@Param("userId") Long userId, Pageable page);
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ public CreditReservationReceipt reserve(Long userId, String requestKey, CreditUs
throw new BusinessException(CreditErrorCode.REQUEST_CONFLICT);
credits.requireSpendingAllowed(userId);
long amount = properties.cost(usage);
if (grants.spendable(userId) < amount) throw new BusinessException(CreditErrorCode.INSUFFICIENT);
var account = credits.account(userId);
account.reserve(amount);
var reservation = reservations.saveAndFlush(CreditReservation.create(userId, requestKey, usage, targetKey, amount, expiresAt));
Expand Down
Loading