[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"skill:01M6CBMKQMVMJDENGPFNPPVC6W":3,"skill-same:01M6CBMKQMVMJDENGPFNPPVC6W":78},{"skillNo":4,"name":5,"nameZh":6,"description":7,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":16,"categories":17,"keywords":23,"publishedAt":30,"linkOnly":31,"markdownContent":32,"markdownBody":33,"frontMatter":34,"refName":35,"commitSha":36,"skillDirectory":37,"forksCount":14,"repositoryHtmlUrl":38,"repositoryHomepageUrl":6,"ownerHtmlUrl":39,"ownerBlogUrl":6,"ownerDescription":40,"updatedAt":41,"install":42,"resources":55},"01M6CBMKQMVMJDENGPFNPPVC6W","45-purchase-refund-processing",null,"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.","devwinsoft\u002Fdevian","devwinsoft","https:\u002F\u002Favatars.githubusercontent.com\u002Fu\u002F22570160?v=4","01M6C6RR8W05NH8NPKQNCE3Q72","maoshy","01M6C6RRDJ5TJJCZXRTY801YDH",0,"MIT","https:\u002F\u002Fgithub.com\u002Fdevwinsoft\u002Fdevian\u002Fblob\u002Fmain\u002Fskills\u002Fdevian-unity\u002F50-mobile-package\u002F30-purchase-system\u002F45-purchase-refund-processing\u002FSKILL.md",[18],{"categoryNo":19,"categoryName":20,"taxonomyType":21,"taxonomyTypeDesc":22},"01JBSEEDCAT000000000000010","后端与接口","SCENARIO","使用场景",[24,25,26,27,28,29],"45 purchase refund processing","workflows","cleanup","state","use","backend","2026-11-10 10:12:30",false,"---\nname: 45-purchase-refund-processing\ndescription: 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.\n---\n\n# 45-purchase-refund-processing — Refund Detection & Processing\n\nStatus: ACTIVE\nAppliesTo: v10\n\n> Purchase SSOT: `..\u002F03-ssot\u002FSKILL.md` (C 섹션: verifyStatus enum)\n\n> Backend 정본: `..\u002F40-purchase-backend-firebase\u002FSKILL.md` (B 섹션: Firestore 스키마)\n\n\n## 문서 경계 (Scope)\n\n- 이 문서는 **환불 감지 → 상태 변경 → 클라이언트 회수** 전체 파이프라인의 구현\u002F운영 정본이다.\n- 포함: RTDN 핸들러, Callable(getPurchaseAdjustments\u002FackRefundApplied), 클라이언트 RefundAsync 흐름, 인프라 셋업 가이드\n- 비포함: Firestore 스키마 정의(→ `40`), Callable 이름 고정(→ `46`), 로컬 저장 구조(→ `33`)\n\n\n---\n\n\n## A. 환불 처리 파이프라인 (Overview)\n\n```\n[Google Play] ─RTDN─→ [Pub\u002FSub] ─→ [handleGooglePlayNotification]\n                                           │\n                                    ┌──────┴──────┐\n                                    │ Firestore    │\n                                    │ verifyStatus │\n                                    │ → REFUNDED   │\n                                    └──────┬──────┘\n                                           │\n[Client App] ─RefundAsync()─→ [getPurchaseAdjustments] ─→ items[]\n                    │\n                    ├─→ ParseRefundSyncResult (REWARD 테이블 해석)\n                    ├─→ RewardManager.RevokeRewardDatasPartial (로컬 인벤토리 회수)\n                    └─→ [ackRefundApplied] ─→ clientRefundApplied=true\n```\n\n### A1. 단계별 역할\n\n| 단계 | 위치 | 역할 |\n|------|------|------|\n| 1. 환불 감지 | Server (Pub\u002FSub) | Google Play RTDN 수신 → `verifyStatus` 변경 |\n| 2. 환불 조회 | Server (Callable) | `getPurchaseAdjustments`: REFUNDED\u002FREVOKED 문서 반환 |\n| 3. 로컬 회수 | Client | `RefundAsync()`: REWARD 테이블 기반 인벤토리 회수 |\n| 4. ACK | Server (Callable) | `ackRefundApplied`: `clientRefundApplied=true` 마킹 |\n\n\n---\n\n\n## B. Server: RTDN Handler (자동화)\n\n### B1. 함수 정보\n\n- 함수명: `handleGooglePlayNotification`\n- 트리거: `onMessagePublished` (Pub\u002FSub)\n- 토픽: `.env.{project}` 의 `RTDN_TOPIC` (예: `devian-play-rtdn`)\n- 리전: `setGlobalOptions` 전역 설정 (index.ts)\n- 시크릿: `GOOGLE_APPLICATION_CREDENTIALS_JSON`\n- 소스: `functions\u002Fsrc\u002Fpurchase\u002FhandleGooglePlayNotification.ts`\n\n### B2. 처리할 RTDN 타입\n\n| RTDN 필드 | 조건 | targetVerifyStatus | 설명 |\n|-----------|------|-------------------|------|\n| `voidedPurchaseNotification` | 존재 | `REFUNDED` | Play Console 환불 (가장 일반적) |\n| `oneTimeProductNotification` | `notificationType == 2` | `REFUNDED` | 사용자 대기 구매 취소 |\n| `subscriptionNotification` | `notificationType == 12` | `REVOKED` | 구독 취소\u002F환불 |\n| `testNotification` | 존재 | — | 연결 테스트 (로그만) |\n| 기타 | — | — | 무시 (로그만) |\n\n### B3. 처리 흐름\n\n1. **메시지 파싱**: `event.data.message.json` 또는 base64 디코딩\n2. **타입 필터**: 환불 관련 타입만 처리 (B2 참조)\n3. **Firestore 조회**: collection group 쿼리 (`storePurchaseId == purchaseToken AND storeKey == \"google\"`)\n4. **멱등성 검사**: 이미 REFUNDED\u002FREVOKED → skip\n5. **상태 검사**: GRANTED만 환불 가능 (그 외 → skip)\n6. **Google Play API 재검증** (defense in depth):\n   - `storeProductId` 확인 가능 시만 실행 (없으면 skip, RTDN 자체 신뢰)\n   - products: `purchaseState === 0` (active) 이면 환불 중단\n7. **Firestore 트랜잭션**:\n   - purchase 문서: `verifyStatus → REFUNDED\u002FREVOKED`, `updatedAt`, `refundedAt`, `refundSource: \"RTDN\"`\n   - entitlements 정리:\n     - Rental: `rentals` 맵에서 `rentalId` (fallback: `internal_product_id`) 키 삭제\n     - SeasonPass: `ownedSeasonPasses[]` 에서 `seasonPassId` 제거\n     - Consumable: entitlements 변경 없음\n\n### B4. 에러 처리\n\n| 상황 | 동작 | 이유 |\n|------|------|------|\n| 파싱 불가 | `return` (재시도 안함) | 메시지 형식 오류 |\n| purchase 문서 미발견 | `return` (재시도 안함) | 문서 없음 |\n| 이미 REFUNDED\u002FREVOKED | `return` | 멱등 |\n| 재검증 실패 (transient) | `throw` | Pub\u002FSub 재시도 |\n| 재검증 결과 active | `return` | 환불 아님 |\n\n\n---\n\n\n## C. Server: Callable (자동화)\n\n### C1. getPurchaseAdjustments\n\n- 쿼리: `verifyStatus in [\"REFUNDED\", \"REVOKED\"]` AND `clientRefundApplied !== true`\n- 응답: `{ items[], nextCursor, hasMore }`\n- 커서: `\"updatedAtMs|docId\"` 형식\n- 페이지: 빈 페이지 방지를 위해 `clientRefundApplied` 필터 후 추가 라운드 실행 (최대 5회)\n\n### C2. ackRefundApplied\n\n- 요청: `{ purchaseId }`\n- 동작: purchase 문서에 `clientRefundApplied: true` (merge update)\n- 문서 삭제 없음 (영구 감사 추적)\n\n\n---\n\n\n## D. Client: RefundAsync (자동화)\n\n- 호출 시점: `initPurchase()` — IAP 초기화 후 앱 시작 시\n- 흐름:\n  1. `getPurchaseAdjustments` 호출 → 환불 항목 조회\n  2. 각 항목의 `internal_product_id` → `ResolveRewardGroupId` → `ResolveRewardDatas`\n  3. `RewardManager.RevokeRewardDatasPartial` 로컬 인벤토리 회수\n  4. `ackRefundApplied` 호출 → 처리 완료 마킹\n- 소스: `PurchaseManager.cs` (`RefundAsync` 메서드)\n\n\n---\n\n\n## E. Firestore 인덱스 (자동화)\n\n`firestore.indexes.json` 에 포함:\n\n### E1. RTDN 핸들러용 (collection group)\n\n```json\n{\n  \"collectionGroup\": \"purchases\",\n  \"queryScope\": \"COLLECTION_GROUP\",\n  \"fields\": [\n    { \"fieldPath\": \"storeKey\", \"order\": \"ASCENDING\" },\n    { \"fieldPath\": \"storePurchaseId\", \"order\": \"ASCENDING\" },\n    { \"fieldPath\": \"__name__\", \"order\": \"ASCENDING\" }\n  ]\n}\n```\n\n### E2. getPurchaseAdjustments용 (collection)\n\n```json\n{\n  \"collectionGroup\": \"purchases\",\n  \"queryScope\": \"COLLECTION\",\n  \"fields\": [\n    { \"fieldPath\": \"verifyStatus\", \"order\": \"ASCENDING\" },\n    { \"fieldPath\": \"updatedAt\", \"order\": \"DESCENDING\" },\n    { \"fieldPath\": \"__name__\", \"order\": \"DESCENDING\" }\n  ]\n}\n```\n\n### E3. storePurchaseId 단일 필드 (collection group)\n\n```json\n\"fieldOverrides\": [{\n  \"collectionGroup\": \"purchases\",\n  \"fieldPath\": \"storePurchaseId\",\n  \"indexes\": [\n    { \"order\": \"ASCENDING\", \"queryScope\": \"COLLECTION_GROUP\" }\n  ]\n}]\n```\n\n\n---\n\n\n## F. 환경 변수 \u002F 시크릿 (자동화)\n\n| 키 | 위치 | 용도 |\n|----|------|------|\n| `RTDN_TOPIC` | `.env.{project}` | Pub\u002FSub 토픽명 |\n| `GOOGLE_APPLICATION_CREDENTIALS_JSON` | Secret Manager | Google Play API 인증 |\n\n리전은 `index.ts`의 `setGlobalOptions({region: \"asia-northeast3\"})` 로 전역 설정.\n\n\n---\n\n\n## G. 수동 설정 가이드 (Manual Setup)\n\n### G1. Pub\u002FSub 토픽 생성\n\n```bash\ngcloud pubsub topics create {RTDN_TOPIC} --project={PROJECT_ID}\n```\n\n> Firebase deploy 시 Eventarc가 자동으로 구독을 생성하므로, 토픽만 미리 생성하면 된다.\n\n### G2. Google Play Console RTDN 설정\n\n1. [Google Play Console](https:\u002F\u002Fplay.google.com\u002Fconsole) 접속\n2. 설정 → 수익 창출 설정 (Monetization setup)\n3. **Real-time developer notifications** 섹션\n4. 토픽 입력: `projects\u002F{PROJECT_ID}\u002Ftopics\u002F{RTDN_TOPIC}`\n5. **저장** 클릭\n6. **Send test notification** 버튼으로 연결 확인\n\n### G3. IAM 설정\n\n#### G3-1. Google Play → Pub\u002FSub (Publisher)\n\nGoogle Play가 RTDN 메시지를 토픽에 발행할 수 있도록:\n\n```bash\ngcloud pubsub topics add-iam-policy-binding {RTDN_TOPIC} \\\n  --member=\"serviceAccount:google-play-developer-notifications@system.gserviceaccount.com\" \\\n  --role=\"roles\u002Fpubsub.publisher\" \\\n  --project={PROJECT_ID}\n```\n\n> `google-play-developer-notifications@system.gserviceaccount.com` 은 Google 관리 시스템 계정이다. 프로젝트에 생성할 필요 없음.\n\n#### G3-2. Pub\u002FSub → Cloud Run (Invoker)\n\nPub\u002FSub 구독이 Cloud Run(Gen 2 함수)을 호출할 수 있도록:\n\n```bash\n# 1) Compute 서비스 계정 확인\ngcloud iam service-accounts list --project={PROJECT_ID} --filter=\"email:compute@developer.gserviceaccount.com\"\n\n# 2) Cloud Run invoker 역할 부여\ngcloud run services add-iam-policy-binding handlegoogleplaynotification \\\n  --region={REGION} \\\n  --member=\"serviceAccount:{PROJECT_NUMBER}-compute@developer.gserviceaccount.com\" \\\n  --role=\"roles\u002Frun.invoker\" \\\n  --project={PROJECT_ID}\n```\n\n### G4. 배포\n\n```bash\n# Functions + Firestore indexes 동시 배포\nfirebase deploy --only functions,firestore:indexes --project={PROJECT_ID}\n```\n\n### G5. 연결 확인 체크리스트\n\n| 단계 | 확인 방법 | 기대 결과 |\n|------|----------|----------|\n| 1. 토픽 존재 | `gcloud pubsub topics describe {RTDN_TOPIC}` | 토픽 정보 출력 |\n| 2. 구독 연결 | `gcloud pubsub topics list-subscriptions {RTDN_TOPIC}` | eventarc 구독 1개 |\n| 3. 연결 테스트 | Play Console \"Send test notification\" | Cloud Run 로그: `Test notification received` |\n| 4. 로그 확인 | 아래 명령 참조 | 함수 실행 로그 |\n\n```bash\n# Cloud Run 로그 확인 (gcloud functions logs 보다 상세)\ngcloud logging read \\\n  'resource.type=\"cloud_run_revision\" AND resource.labels.service_name=\"handlegoogleplaynotification\"' \\\n  --project={PROJECT_ID} \\\n  --limit=20 \\\n  --format=\"table(timestamp,severity,textPayload)\" \\\n  --freshness=30m\n```\n\n\n---\n\n\n## H. 제한사항\n\n1. **라이선스 테스터 구매**: Google Play는 테스트 구매에 대해 `voidedPurchaseNotification` RTDN을 보내지 않는다. 실제 금전 거래가 있는 프로덕션 구매만 자동 감지된다.\n2. **Apple**: 현재 Apple RTDN (App Store Server Notifications v2) 미구현. 향후 별도 스킬로 추가.\n3. **수동 시뮬레이션**: 테스트 환경에서는 Pub\u002FSub 메시지를 수동 발행하여 핸들러를 검증할 수 있다:\n\n```bash\ngcloud pubsub topics publish projects\u002F{PROJECT_ID}\u002Ftopics\u002F{RTDN_TOPIC} \\\n  --message='{\"version\":\"1.0\",\"packageName\":\"{PACKAGE_NAME}\",\"eventTimeMillis\":\"...\",\"voidedPurchaseNotification\":{\"purchaseToken\":\"{TOKEN}\",\"orderId\":\"{ORDER_ID}\",\"productType\":1,\"refundType\":0}}'\n```\n\n\n---\n\n\n## I. 관련 정본 링크\n\n- Purchase SSOT: `..\u002F03-ssot\u002FSKILL.md`\n- Backend(40): `..\u002F40-purchase-backend-firebase\u002FSKILL.md`\n- Decisions(46): `..\u002F46-purchase-decisions\u002FSKILL.md`\n- PurchaseStorage(33): `..\u002F33-purchase-storage\u002FSKILL.md`\n- Client-Server Integration(43): `..\u002F43-purchase-client-server-integration\u002FSKILL.md`\n- Operations(09): `..\u002F09-ssot-operations\u002FSKILL.md`\n\n\n---\n\n\n## DoD\n\nHard (must be 0)\n- [x] RTDN 핸들러가 `voidedPurchaseNotification` \u002F `oneTimeProductNotification(2)` \u002F `subscriptionNotification(12)` 를 처리한다.\n- [x] purchase 문서 `verifyStatus` → `REFUNDED`\u002F`REVOKED` 변경이 Firestore 트랜잭션으로 수행된다.\n- [x] entitlements 정리(Rental\u002FSeasonPass)가 동일 트랜잭션에서 수행된다.\n- [x] 멱등성: 이미 REFUNDED\u002FREVOKED인 문서 재처리 방지.\n- [x] Firestore collection group 인덱스(`storeKey` + `storePurchaseId`)가 배포되어 있다.\n- [x] IAM 설정(Publisher + Invoker)이 완료되어 있다.\n- [x] 클라이언트 `RefundAsync` → `getPurchaseAdjustments` → `RewardManager.RevokeRewardDatasPartial` → `ackRefundApplied` 흐름이 동작한다.\n\nSoft\n- [ ] Apple App Store Server Notifications v2 구현\n- [ ] Voided Purchases API 폴링 (보조 경로) 구현\n","# 45-purchase-refund-processing — Refund Detection & Processing\n\nStatus: ACTIVE\nAppliesTo: v10\n\n> Purchase SSOT: `..\u002F03-ssot\u002FSKILL.md` (C 섹션: verifyStatus enum)\n\n> Backend 정본: `..\u002F40-purchase-backend-firebase\u002FSKILL.md` (B 섹션: Firestore 스키마)\n\n\n## 문서 경계 (Scope)\n\n- 이 문서는 **환불 감지 → 상태 변경 → 클라이언트 회수** 전체 파이프라인의 구현\u002F운영 정본이다.\n- 포함: RTDN 핸들러, Callable(getPurchaseAdjustments\u002FackRefundApplied), 클라이언트 RefundAsync 흐름, 인프라 셋업 가이드\n- 비포함: Firestore 스키마 정의(→ `40`), Callable 이름 고정(→ `46`), 로컬 저장 구조(→ `33`)\n\n\n---\n\n\n## A. 환불 처리 파이프라인 (Overview)\n\n```\n[Google Play] ─RTDN─→ [Pub\u002FSub] ─→ [handleGooglePlayNotification]\n                                           │\n                                    ┌──────┴──────┐\n                                    │ Firestore    │\n                                    │ verifyStatus │\n                                    │ → REFUNDED   │\n                                    └──────┬──────┘\n                                           │\n[Client App] ─RefundAsync()─→ [getPurchaseAdjustments] ─→ items[]\n                    │\n                    ├─→ ParseRefundSyncResult (REWARD 테이블 해석)\n                    ├─→ RewardManager.RevokeRewardDatasPartial (로컬 인벤토리 회수)\n                    └─→ [ackRefundApplied] ─→ clientRefundApplied=true\n```\n\n### A1. 단계별 역할\n\n| 단계 | 위치 | 역할 |\n|------|------|------|\n| 1. 환불 감지 | Server (Pub\u002FSub) | Google Play RTDN 수신 → `verifyStatus` 변경 |\n| 2. 환불 조회 | Server (Callable) | `getPurchaseAdjustments`: REFUNDED\u002FREVOKED 문서 반환 |\n| 3. 로컬 회수 | Client | `RefundAsync()`: REWARD 테이블 기반 인벤토리 회수 |\n| 4. ACK | Server (Callable) | `ackRefundApplied`: `clientRefundApplied=true` 마킹 |\n\n\n---\n\n\n## B. Server: RTDN Handler (자동화)\n\n### B1. 함수 정보\n\n- 함수명: `handleGooglePlayNotification`\n- 트리거: `onMessagePublished` (Pub\u002FSub)\n- 토픽: `.env.{project}` 의 `RTDN_TOPIC` (예: `devian-play-rtdn`)\n- 리전: `setGlobalOptions` 전역 설정 (index.ts)\n- 시크릿: `GOOGLE_APPLICATION_CREDENTIALS_JSON`\n- 소스: `functions\u002Fsrc\u002Fpurchase\u002FhandleGooglePlayNotification.ts`\n\n### B2. 처리할 RTDN 타입\n\n| RTDN 필드 | 조건 | targetVerifyStatus | 설명 |\n|-----------|------|-------------------|------|\n| `voidedPurchaseNotification` | 존재 | `REFUNDED` | Play Console 환불 (가장 일반적) |\n| `oneTimeProductNotification` | `notificationType == 2` | `REFUNDED` | 사용자 대기 구매 취소 |\n| `subscriptionNotification` | `notificationType == 12` | `REVOKED` | 구독 취소\u002F환불 |\n| `testNotification` | 존재 | — | 연결 테스트 (로그만) |\n| 기타 | — | — | 무시 (로그만) |\n\n### B3. 처리 흐름\n\n1. **메시지 파싱**: `event.data.message.json` 또는 base64 디코딩\n2. **타입 필터**: 환불 관련 타입만 처리 (B2 참조)\n3. **Firestore 조회**: collection group 쿼리 (`storePurchaseId == purchaseToken AND storeKey == \"google\"`)\n4. **멱등성 검사**: 이미 REFUNDED\u002FREVOKED → skip\n5. **상태 검사**: GRANTED만 환불 가능 (그 외 → skip)\n6. **Google Play API 재검증** (defense in depth):\n   - `storeProductId` 확인 가능 시만 실행 (없으면 skip, RTDN 자체 신뢰)\n   - products: `purchaseState === 0` (active) 이면 환불 중단\n7. **Firestore 트랜잭션**:\n   - purchase 문서: `verifyStatus → REFUNDED\u002FREVOKED`, `updatedAt`, `refundedAt`, `refundSource: \"RTDN\"`\n   - entitlements 정리:\n     - Rental: `rentals` 맵에서 `rentalId` (fallback: `internal_product_id`) 키 삭제\n     - SeasonPass: `ownedSeasonPasses[]` 에서 `seasonPassId` 제거\n     - Consumable: entitlements 변경 없음\n\n### B4. 에러 처리\n\n| 상황 | 동작 | 이유 |\n|------|------|------|\n| 파싱 불가 | `return` (재시도 안함) | 메시지 형식 오류 |\n| purchase 문서 미발견 | `return` (재시도 안함) | 문서 없음 |\n| 이미 REFUNDED\u002FREVOKED | `return` | 멱등 |\n| 재검증 실패 (transient) | `throw` | Pub\u002FSub 재시도 |\n| 재검증 결과 active | `return` | 환불 아님 |\n\n\n---\n\n\n## C. Server: Callable (자동화)\n\n### C1. getPurchaseAdjustments\n\n- 쿼리: `verifyStatus in [\"REFUNDED\", \"REVOKED\"]` AND `clientRefundApplied !== true`\n- 응답: `{ items[], nextCursor, hasMore }`\n- 커서: `\"updatedAtMs|docId\"` 형식\n- 페이지: 빈 페이지 방지를 위해 `clientRefundApplied` 필터 후 추가 라운드 실행 (최대 5회)\n\n### C2. ackRefundApplied\n\n- 요청: `{ purchaseId }`\n- 동작: purchase 문서에 `clientRefundApplied: true` (merge update)\n- 문서 삭제 없음 (영구 감사 추적)\n\n\n---\n\n\n## D. Client: RefundAsync (자동화)\n\n- 호출 시점: `initPurchase()` — IAP 초기화 후 앱 시작 시\n- 흐름:\n  1. `getPurchaseAdjustments` 호출 → 환불 항목 조회\n  2. 각 항목의 `internal_product_id` → `ResolveRewardGroupId` → `ResolveRewardDatas`\n  3. `RewardManager.RevokeRewardDatasPartial` 로컬 인벤토리 회수\n  4. `ackRefundApplied` 호출 → 처리 완료 마킹\n- 소스: `PurchaseManager.cs` (`RefundAsync` 메서드)\n\n\n---\n\n\n## E. Firestore 인덱스 (자동화)\n\n`firestore.indexes.json` 에 포함:\n\n### E1. RTDN 핸들러용 (collection group)\n\n```json\n{\n  \"collectionGroup\": \"purchases\",\n  \"queryScope\": \"COLLECTION_GROUP\",\n  \"fields\": [\n    { \"fieldPath\": \"storeKey\", \"order\": \"ASCENDING\" },\n    { \"fieldPath\": \"storePurchaseId\", \"order\": \"ASCENDING\" },\n    { \"fieldPath\": \"__name__\", \"order\": \"ASCENDING\" }\n  ]\n}\n```\n\n### E2. getPurchaseAdjustments용 (collection)\n\n```json\n{\n  \"collectionGroup\": \"purchases\",\n  \"queryScope\": \"COLLECTION\",\n  \"fields\": [\n    { \"fieldPath\": \"verifyStatus\", \"order\": \"ASCENDING\" },\n    { \"fieldPath\": \"updatedAt\", \"order\": \"DESCENDING\" },\n    { \"fieldPath\": \"__name__\", \"order\": \"DESCENDING\" }\n  ]\n}\n```\n\n### E3. storePurchaseId 단일 필드 (collection group)\n\n```json\n\"fieldOverrides\": [{\n  \"collectionGroup\": \"purchases\",\n  \"fieldPath\": \"storePurchaseId\",\n  \"indexes\": [\n    { \"order\": \"ASCENDING\", \"queryScope\": \"COLLECTION_GROUP\" }\n  ]\n}]\n```\n\n\n---\n\n\n## F. 환경 변수 \u002F 시크릿 (자동화)\n\n| 키 | 위치 | 용도 |\n|----|------|------|\n| `RTDN_TOPIC` | `.env.{project}` | Pub\u002FSub 토픽명 |\n| `GOOGLE_APPLICATION_CREDENTIALS_JSON` | Secret Manager | Google Play API 인증 |\n\n리전은 `index.ts`의 `setGlobalOptions({region: \"asia-northeast3\"})` 로 전역 설정.\n\n\n---\n\n\n## G. 수동 설정 가이드 (Manual Setup)\n\n### G1. Pub\u002FSub 토픽 생성\n\n```bash\ngcloud pubsub topics create {RTDN_TOPIC} --project={PROJECT_ID}\n```\n\n> Firebase deploy 시 Eventarc가 자동으로 구독을 생성하므로, 토픽만 미리 생성하면 된다.\n\n### G2. Google Play Console RTDN 설정\n\n1. [Google Play Console](https:\u002F\u002Fplay.google.com\u002Fconsole) 접속\n2. 설정 → 수익 창출 설정 (Monetization setup)\n3. **Real-time developer notifications** 섹션\n4. 토픽 입력: `projects\u002F{PROJECT_ID}\u002Ftopics\u002F{RTDN_TOPIC}`\n5. **저장** 클릭\n6. **Send test notification** 버튼으로 연결 확인\n\n### G3. IAM 설정\n\n#### G3-1. Google Play → Pub\u002FSub (Publisher)\n\nGoogle Play가 RTDN 메시지를 토픽에 발행할 수 있도록:\n\n```bash\ngcloud pubsub topics add-iam-policy-binding {RTDN_TOPIC} \\\n  --member=\"serviceAccount:google-play-developer-notifications@system.gserviceaccount.com\" \\\n  --role=\"roles\u002Fpubsub.publisher\" \\\n  --project={PROJECT_ID}\n```\n\n> `google-play-developer-notifications@system.gserviceaccount.com` 은 Google 관리 시스템 계정이다. 프로젝트에 생성할 필요 없음.\n\n#### G3-2. Pub\u002FSub → Cloud Run (Invoker)\n\nPub\u002FSub 구독이 Cloud Run(Gen 2 함수)을 호출할 수 있도록:\n\n```bash\n# 1) Compute 서비스 계정 확인\ngcloud iam service-accounts list --project={PROJECT_ID} --filter=\"email:compute@developer.gserviceaccount.com\"\n\n# 2) Cloud Run invoker 역할 부여\ngcloud run services add-iam-policy-binding handlegoogleplaynotification \\\n  --region={REGION} \\\n  --member=\"serviceAccount:{PROJECT_NUMBER}-compute@developer.gserviceaccount.com\" \\\n  --role=\"roles\u002Frun.invoker\" \\\n  --project={PROJECT_ID}\n```\n\n### G4. 배포\n\n```bash\n# Functions + Firestore indexes 동시 배포\nfirebase deploy --only functions,firestore:indexes --project={PROJECT_ID}\n```\n\n### G5. 연결 확인 체크리스트\n\n| 단계 | 확인 방법 | 기대 결과 |\n|------|----------|----------|\n| 1. 토픽 존재 | `gcloud pubsub topics describe {RTDN_TOPIC}` | 토픽 정보 출력 |\n| 2. 구독 연결 | `gcloud pubsub topics list-subscriptions {RTDN_TOPIC}` | eventarc 구독 1개 |\n| 3. 연결 테스트 | Play Console \"Send test notification\" | Cloud Run 로그: `Test notification received` |\n| 4. 로그 확인 | 아래 명령 참조 | 함수 실행 로그 |\n\n```bash\n# Cloud Run 로그 확인 (gcloud functions logs 보다 상세)\ngcloud logging read \\\n  'resource.type=\"cloud_run_revision\" AND resource.labels.service_name=\"handlegoogleplaynotification\"' \\\n  --project={PROJECT_ID} \\\n  --limit=20 \\\n  --format=\"table(timestamp,severity,textPayload)\" \\\n  --freshness=30m\n```\n\n\n---\n\n\n## H. 제한사항\n\n1. **라이선스 테스터 구매**: Google Play는 테스트 구매에 대해 `voidedPurchaseNotification` RTDN을 보내지 않는다. 실제 금전 거래가 있는 프로덕션 구매만 자동 감지된다.\n2. **Apple**: 현재 Apple RTDN (App Store Server Notifications v2) 미구현. 향후 별도 스킬로 추가.\n3. **수동 시뮬레이션**: 테스트 환경에서는 Pub\u002FSub 메시지를 수동 발행하여 핸들러를 검증할 수 있다:\n\n```bash\ngcloud pubsub topics publish projects\u002F{PROJECT_ID}\u002Ftopics\u002F{RTDN_TOPIC} \\\n  --message='{\"version\":\"1.0\",\"packageName\":\"{PACKAGE_NAME}\",\"eventTimeMillis\":\"...\",\"voidedPurchaseNotification\":{\"purchaseToken\":\"{TOKEN}\",\"orderId\":\"{ORDER_ID}\",\"productType\":1,\"refundType\":0}}'\n```\n\n\n---\n\n\n## I. 관련 정본 링크\n\n- Purchase SSOT: `..\u002F03-ssot\u002FSKILL.md`\n- Backend(40): `..\u002F40-purchase-backend-firebase\u002FSKILL.md`\n- Decisions(46): `..\u002F46-purchase-decisions\u002FSKILL.md`\n- PurchaseStorage(33): `..\u002F33-purchase-storage\u002FSKILL.md`\n- Client-Server Integration(43): `..\u002F43-purchase-client-server-integration\u002FSKILL.md`\n- Operations(09): `..\u002F09-ssot-operations\u002FSKILL.md`\n\n\n---\n\n\n## DoD\n\nHard (must be 0)\n- [x] RTDN 핸들러가 `voidedPurchaseNotification` \u002F `oneTimeProductNotification(2)` \u002F `subscriptionNotification(12)` 를 처리한다.\n- [x] purchase 문서 `verifyStatus` → `REFUNDED`\u002F`REVOKED` 변경이 Firestore 트랜잭션으로 수행된다.\n- [x] entitlements 정리(Rental\u002FSeasonPass)가 동일 트랜잭션에서 수행된다.\n- [x] 멱등성: 이미 REFUNDED\u002FREVOKED인 문서 재처리 방지.\n- [x] Firestore collection group 인덱스(`storeKey` + `storePurchaseId`)가 배포되어 있다.\n- [x] IAM 설정(Publisher + Invoker)이 완료되어 있다.\n- [x] 클라이언트 `RefundAsync` → `getPurchaseAdjustments` → `RewardManager.RevokeRewardDatasPartial` → `ackRefundApplied` 흐름이 동작한다.\n\nSoft\n- [ ] Apple App Store Server Notifications v2 구현\n- [ ] Voided Purchases API 폴링 (보조 경로) 구현\n",{"name":5,"description":7},"main","8cb5bd877a3a7004867d2f55697cb9e4ff2f1734","skills\u002Fdevian-unity\u002F50-mobile-package\u002F30-purchase-system\u002F45-purchase-refund-processing","https:\u002F\u002Fgithub.com\u002Fdevwinsoft\u002Fdevian","https:\u002F\u002Fgithub.com\u002Fdevwinsoft","Kim Hyong Joon","2026-04-12 18:44:10",{"source":8,"skillName":5,"commands":43,"prompt":53,"zipAvailable":54},[44,47,50],{"tool":45,"command":46},"npx","npx skills add devwinsoft\u002Fdevian --skill 45-purchase-refund-processing",{"tool":48,"command":49},"bunx","bunx skills add devwinsoft\u002Fdevian --skill 45-purchase-refund-processing",{"tool":51,"command":52},"pnpm","pnpm dlx skills add devwinsoft\u002Fdevian --skill 45-purchase-refund-processing","安装技能，技能地址：https:\u002F\u002Fgithub.com\u002Fdevwinsoft\u002Fdevian\u002Fblob\u002Fmain\u002Fskills\u002Fdevian-unity\u002F50-mobile-package\u002F30-purchase-system\u002F45-purchase-refund-processing\u002FSKILL.md",true,[56,62,66,70,74],{"path":57,"resourceType":58,"resourceTypeDesc":59,"sizeBytes":60,"sourceHtmlUrl":61},"agents\u002Fopenai.yaml","REFERENCE","参考文档",199,"https:\u002F\u002Fgithub.com\u002Fjiayaoqijia\u002Fcryptoskill\u002Fblob\u002Fmain\u002Fskills\u002Fchains\u002Fton-blockchain-tolk\u002Fagents\u002Fopenai.yaml",{"path":63,"resourceType":58,"resourceTypeDesc":59,"sizeBytes":64,"sourceHtmlUrl":65},"references\u002Fdevelopment-checklist.md",5245,"https:\u002F\u002Fgithub.com\u002Fjiayaoqijia\u002Fcryptoskill\u002Fblob\u002Fmain\u002Fskills\u002Fchains\u002Fton-blockchain-tolk\u002Freferences\u002Fdevelopment-checklist.md",{"path":67,"resourceType":58,"resourceTypeDesc":59,"sizeBytes":68,"sourceHtmlUrl":69},"references\u002Fidiomatic-patterns.md",10308,"https:\u002F\u002Fgithub.com\u002Fjiayaoqijia\u002Fcryptoskill\u002Fblob\u002Fmain\u002Fskills\u002Fchains\u002Fton-blockchain-tolk\u002Freferences\u002Fidiomatic-patterns.md",{"path":71,"resourceType":58,"resourceTypeDesc":59,"sizeBytes":72,"sourceHtmlUrl":73},"SOURCE.md",332,"https:\u002F\u002Fgithub.com\u002Fjiayaoqijia\u002Fcryptoskill\u002Fblob\u002Fmain\u002Fskills\u002Fchains\u002Fton-blockchain-tolk\u002FSOURCE.md",{"path":75,"resourceType":58,"resourceTypeDesc":59,"sizeBytes":76,"sourceHtmlUrl":77},"TRUST.auto.yaml",2114,"https:\u002F\u002Fgithub.com\u002Fjiayaoqijia\u002Fcryptoskill\u002Fblob\u002Fmain\u002Fskills\u002Fchains\u002Fton-blockchain-tolk\u002FTRUST.auto.yaml",[79,85,93,101,110,117],{"skillNo":80,"name":81,"nameZh":6,"description":6,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":82,"categories":83,"keywords":84,"publishedAt":30},"01M6CBX3K2JJV38ANHVGCAT8A0","devian","https:\u002F\u002Fgithub.com\u002Fdevwinsoft\u002Fdevian\u002Fblob\u002Fmain\u002Fskills\u002Fdevian\u002FSKILL.md",[],[81],{"skillNo":86,"name":87,"nameZh":6,"description":6,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":88,"categories":89,"keywords":90,"publishedAt":30},"01M6CBX210BFMM8AMN770J9WY8","90-project-archive","https:\u002F\u002Fgithub.com\u002Fdevwinsoft\u002Fdevian\u002Fblob\u002Fmain\u002Fskills\u002Fdevian\u002F80-tools\u002F90-project-archive\u002FSKILL.md",[],[91,92],"archive","90 project archive",{"skillNo":94,"name":95,"nameZh":6,"description":6,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":96,"categories":97,"keywords":98,"publishedAt":30},"01M6CBWZSGRR9R3YJZB3177667","30-env-management","https:\u002F\u002Fgithub.com\u002Fdevwinsoft\u002Fdevian\u002Fblob\u002Fmain\u002Fskills\u002Fdevian\u002F80-tools\u002F30-env-management\u002FSKILL.md",[],[99,100],"management","30 env management",{"skillNo":102,"name":103,"nameZh":6,"description":6,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":104,"categories":105,"keywords":106,"publishedAt":30},"01M6CBWYD3TGWBQV7V35551THF","19-page-push-send","https:\u002F\u002Fgithub.com\u002Fdevwinsoft\u002Fdevian\u002Fblob\u002Fmain\u002Fskills\u002Fdevian\u002F80-tools\u002F20-operation\u002F19-page-push-send\u002FSKILL.md",[],[107,108,109],"push","page","19 page push send",{"skillNo":111,"name":112,"nameZh":6,"description":6,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":113,"categories":114,"keywords":115,"publishedAt":30},"01M6CBWWRQ05A7604GHJKTPJTF","18-page-savedata","https:\u002F\u002Fgithub.com\u002Fdevwinsoft\u002Fdevian\u002Fblob\u002Fmain\u002Fskills\u002Fdevian\u002F80-tools\u002F20-operation\u002F18-page-savedata\u002FSKILL.md",[],[108,116],"18 page savedata",{"skillNo":118,"name":119,"nameZh":6,"description":6,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":120,"categories":121,"keywords":122,"publishedAt":30},"01M6CBWVH4S2VF1V7B2GBTESCT","17-page-deobfuscate","https:\u002F\u002Fgithub.com\u002Fdevwinsoft\u002Fdevian\u002Fblob\u002Fmain\u002Fskills\u002Fdevian\u002F80-tools\u002F20-operation\u002F17-page-deobfuscate\u002FSKILL.md",[],[108,123],"17 page deobfuscate"]