AGENT SKILL
45-purchase-refund-processing
Define refund and revoke processing for the purchase backend. Use when implementing RTDN handling, purchase state transitions to REFUNDED or REVOKED, entitlement cleanup, and client refund adjustment workflows.
使用场景后端与接口
- License 许可
- MIT
- 最近更新
- 2026-04-12
- Stars
- ★ 0
- Forks
- 0
01 前言 Front Matter
- name
- 45-purchase-refund-processing
- description
- Define refund and revoke processing for the purchase backend. Use when implementing RTDN handling, purchase state transitions to REFUNDED or REVOKED, entitlement cleanup, and client refund adjustment workflows.
02 资源文件 5
- 参考文档agents/openai.yaml199 B
- 参考文档references/development-checklist.md5.1 KB
- 参考文档references/idiomatic-patterns.md10.1 KB
- 参考文档SOURCE.md332 B
- 参考文档TRUST.auto.yaml2.1 KB
03 SKILL.md 正文
45-purchase-refund-processing — Refund Detection & Processing
Status: ACTIVE AppliesTo: v10
Purchase SSOT:
../03-ssot/SKILL.md(C 섹션: verifyStatus enum)
Backend 정본:
../40-purchase-backend-firebase/SKILL.md(B 섹션: Firestore 스키마)
문서 경계 (Scope)
- 이 문서는 환불 감지 → 상태 변경 → 클라이언트 회수 전체 파이프라인의 구현/운영 정본이다.
- 포함: RTDN 핸들러, Callable(getPurchaseAdjustments/ackRefundApplied), 클라이언트 RefundAsync 흐름, 인프라 셋업 가이드
- 비포함: Firestore 스키마 정의(→
40), Callable 이름 고정(→46), 로컬 저장 구조(→33)
A. 환불 처리 파이프라인 (Overview)
[Google Play] ─RTDN─→ [Pub/Sub] ─→ [handleGooglePlayNotification]
│
┌──────┴──────┐
│ Firestore │
│ verifyStatus │
│ → REFUNDED │
└──────┬──────┘
│
[Client App] ─RefundAsync()─→ [getPurchaseAdjustments] ─→ items[]
│
├─→ ParseRefundSyncResult (REWARD 테이블 해석)
├─→ RewardManager.RevokeRewardDatasPartial (로컬 인벤토리 회수)
└─→ [ackRefundApplied] ─→ clientRefundApplied=true
A1. 단계별 역할
| 단계 | 위치 | 역할 |
|---|---|---|
| 1. 환불 감지 | Server (Pub/Sub) | Google Play RTDN 수신 → verifyStatus 변경 |
| 2. 환불 조회 | Server (Callable) | getPurchaseAdjustments: REFUNDED/REVOKED 문서 반환 |
| 3. 로컬 회수 | Client | RefundAsync(): REWARD 테이블 기반 인벤토리 회수 |
| 4. ACK | Server (Callable) | ackRefundApplied: clientRefundApplied=true 마킹 |
B. Server: RTDN Handler (자동화)
B1. 함수 정보
- 함수명:
handleGooglePlayNotification - 트리거:
onMessagePublished(Pub/Sub) - 토픽:
.env.{project}의RTDN_TOPIC(예:devian-play-rtdn) - 리전:
setGlobalOptions전역 설정 (index.ts) - 시크릿:
GOOGLE_APPLICATION_CREDENTIALS_JSON - 소스:
functions/src/purchase/handleGooglePlayNotification.ts
B2. 처리할 RTDN 타입
| RTDN 필드 | 조건 | targetVerifyStatus | 설명 |
|---|---|---|---|
voidedPurchaseNotification |
존재 | REFUNDED |
Play Console 환불 (가장 일반적) |
oneTimeProductNotification |
notificationType == 2 |
REFUNDED |
사용자 대기 구매 취소 |
subscriptionNotification |
notificationType == 12 |
REVOKED |
구독 취소/환불 |
testNotification |
존재 | — | 연결 테스트 (로그만) |
| 기타 | — | — | 무시 (로그만) |
B3. 처리 흐름
- 메시지 파싱:
event.data.message.json또는 base64 디코딩 - 타입 필터: 환불 관련 타입만 처리 (B2 참조)
- Firestore 조회: collection group 쿼리 (
storePurchaseId == purchaseToken AND storeKey == "google") - 멱등성 검사: 이미 REFUNDED/REVOKED → skip
- 상태 검사: GRANTED만 환불 가능 (그 외 → skip)
- Google Play API 재검증 (defense in depth):
storeProductId확인 가능 시만 실행 (없으면 skip, RTDN 자체 신뢰)- products:
purchaseState === 0(active) 이면 환불 중단
- Firestore 트랜잭션:
- purchase 문서:
verifyStatus → REFUNDED/REVOKED,updatedAt,refundedAt,refundSource: "RTDN" - entitlements 정리:
- Rental:
rentals맵에서rentalId(fallback:internal_product_id) 키 삭제 - SeasonPass:
ownedSeasonPasses[]에서seasonPassId제거 - Consumable: entitlements 변경 없음
- Rental:
- purchase 문서:
B4. 에러 처리
| 상황 | 동작 | 이유 |
|---|---|---|
| 파싱 불가 | return (재시도 안함) |
메시지 형식 오류 |
| purchase 문서 미발견 | return (재시도 안함) |
문서 없음 |
| 이미 REFUNDED/REVOKED | return |
멱등 |
| 재검증 실패 (transient) | throw |
Pub/Sub 재시도 |
| 재검증 결과 active | return |
환불 아님 |
C. Server: Callable (자동화)
C1. getPurchaseAdjustments
- 쿼리:
verifyStatus in ["REFUNDED", "REVOKED"]ANDclientRefundApplied !== true - 응답:
{ items[], nextCursor, hasMore } - 커서:
"updatedAtMs|docId"형식 - 페이지: 빈 페이지 방지를 위해
clientRefundApplied필터 후 추가 라운드 실행 (최대 5회)
C2. ackRefundApplied
- 요청:
{ purchaseId } - 동작: purchase 문서에
clientRefundApplied: true(merge update) - 문서 삭제 없음 (영구 감사 추적)
D. Client: RefundAsync (자동화)
- 호출 시점:
initPurchase()— IAP 초기화 후 앱 시작 시 - 흐름:
getPurchaseAdjustments호출 → 환불 항목 조회- 각 항목의
internal_product_id→ResolveRewardGroupId→ResolveRewardDatas RewardManager.RevokeRewardDatasPartial로컬 인벤토리 회수ackRefundApplied호출 → 처리 완료 마킹
- 소스:
PurchaseManager.cs(RefundAsync메서드)
E. Firestore 인덱스 (자동화)
firestore.indexes.json 에 포함:
E1. RTDN 핸들러용 (collection group)
{
"collectionGroup": "purchases",
"queryScope": "COLLECTION_GROUP",
"fields": [
{ "fieldPath": "storeKey", "order": "ASCENDING" },
{ "fieldPath": "storePurchaseId", "order": "ASCENDING" },
{ "fieldPath": "__name__", "order": "ASCENDING" }
]
}
E2. getPurchaseAdjustments용 (collection)
{
"collectionGroup": "purchases",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "verifyStatus", "order": "ASCENDING" },
{ "fieldPath": "updatedAt", "order": "DESCENDING" },
{ "fieldPath": "__name__", "order": "DESCENDING" }
]
}
E3. storePurchaseId 단일 필드 (collection group)
"fieldOverrides": [{
"collectionGroup": "purchases",
"fieldPath": "storePurchaseId",
"indexes": [
{ "order": "ASCENDING", "queryScope": "COLLECTION_GROUP" }
]
}]
F. 환경 변수 / 시크릿 (자동화)
| 키 | 위치 | 용도 |
|---|---|---|
RTDN_TOPIC |
.env.{project} |
Pub/Sub 토픽명 |
GOOGLE_APPLICATION_CREDENTIALS_JSON |
Secret Manager | Google Play API 인증 |
리전은 index.ts의 setGlobalOptions({region: "asia-northeast3"}) 로 전역 설정.
G. 수동 설정 가이드 (Manual Setup)
G1. Pub/Sub 토픽 생성
gcloud pubsub topics create {RTDN_TOPIC} --project={PROJECT_ID}
Firebase deploy 시 Eventarc가 자동으로 구독을 생성하므로, 토픽만 미리 생성하면 된다.
G2. Google Play Console RTDN 설정
- Google Play Console 접속
- 설정 → 수익 창출 설정 (Monetization setup)
- Real-time developer notifications 섹션
- 토픽 입력:
projects/{PROJECT_ID}/topics/{RTDN_TOPIC} - 저장 클릭
- Send test notification 버튼으로 연결 확인
G3. IAM 설정
G3-1. Google Play → Pub/Sub (Publisher)
Google Play가 RTDN 메시지를 토픽에 발행할 수 있도록:
gcloud pubsub topics add-iam-policy-binding {RTDN_TOPIC} \
--member="serviceAccount:google-play-developer-notifications@system.gserviceaccount.com" \
--role="roles/pubsub.publisher" \
--project={PROJECT_ID}
google-play-developer-notifications@system.gserviceaccount.com은 Google 관리 시스템 계정이다. 프로젝트에 생성할 필요 없음.
G3-2. Pub/Sub → Cloud Run (Invoker)
Pub/Sub 구독이 Cloud Run(Gen 2 함수)을 호출할 수 있도록:
# 1) Compute 서비스 계정 확인
gcloud iam service-accounts list --project={PROJECT_ID} --filter="email:compute@developer.gserviceaccount.com"
# 2) Cloud Run invoker 역할 부여
gcloud run services add-iam-policy-binding handlegoogleplaynotification \
--region={REGION} \
--member="serviceAccount:{PROJECT_NUMBER}-compute@developer.gserviceaccount.com" \
--role="roles/run.invoker" \
--project={PROJECT_ID}
G4. 배포
# Functions + Firestore indexes 동시 배포
firebase deploy --only functions,firestore:indexes --project={PROJECT_ID}
G5. 연결 확인 체크리스트
| 단계 | 확인 방법 | 기대 결과 |
|---|---|---|
| 1. 토픽 존재 | gcloud pubsub topics describe {RTDN_TOPIC} |
토픽 정보 출력 |
| 2. 구독 연결 | gcloud pubsub topics list-subscriptions {RTDN_TOPIC} |
eventarc 구독 1개 |
| 3. 연결 테스트 | Play Console "Send test notification" | Cloud Run 로그: Test notification received |
| 4. 로그 확인 | 아래 명령 참조 | 함수 실행 로그 |
# Cloud Run 로그 확인 (gcloud functions logs 보다 상세)
gcloud logging read \
'resource.type="cloud_run_revision" AND resource.labels.service_name="handlegoogleplaynotification"' \
--project={PROJECT_ID} \
--limit=20 \
--format="table(timestamp,severity,textPayload)" \
--freshness=30m
H. 제한사항
- 라이선스 테스터 구매: Google Play는 테스트 구매에 대해
voidedPurchaseNotificationRTDN을 보내지 않는다. 실제 금전 거래가 있는 프로덕션 구매만 자동 감지된다. - Apple: 현재 Apple RTDN (App Store Server Notifications v2) 미구현. 향후 별도 스킬로 추가.
- 수동 시뮬레이션: 테스트 환경에서는 Pub/Sub 메시지를 수동 발행하여 핸들러를 검증할 수 있다:
gcloud pubsub topics publish projects/{PROJECT_ID}/topics/{RTDN_TOPIC} \
--message='{"version":"1.0","packageName":"{PACKAGE_NAME}","eventTimeMillis":"...","voidedPurchaseNotification":{"purchaseToken":"{TOKEN}","orderId":"{ORDER_ID}","productType":1,"refundType":0}}'
I. 관련 정본 링크
- Purchase SSOT:
../03-ssot/SKILL.md - Backend(40):
../40-purchase-backend-firebase/SKILL.md - Decisions(46):
../46-purchase-decisions/SKILL.md - PurchaseStorage(33):
../33-purchase-storage/SKILL.md - Client-Server Integration(43):
../43-purchase-client-server-integration/SKILL.md - Operations(09):
../09-ssot-operations/SKILL.md
DoD
Hard (must be 0)
- [x] RTDN 핸들러가
voidedPurchaseNotification/oneTimeProductNotification(2)/subscriptionNotification(12)를 처리한다. - [x] purchase 문서
verifyStatus→REFUNDED/REVOKED변경이 Firestore 트랜잭션으로 수행된다. - [x] entitlements 정리(Rental/SeasonPass)가 동일 트랜잭션에서 수행된다.
- [x] 멱등성: 이미 REFUNDED/REVOKED인 문서 재처리 방지.
- [x] Firestore collection group 인덱스(
storeKey+storePurchaseId)가 배포되어 있다. - [x] IAM 설정(Publisher + Invoker)이 완료되어 있다.
- [x] 클라이언트
RefundAsync→getPurchaseAdjustments→RewardManager.RevokeRewardDatasPartial→ackRefundApplied흐름이 동작한다.
Soft
- [ ] Apple App Store Server Notifications v2 구현
- [ ] Voided Purchases API 폴링 (보조 경로) 구현