[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"skill:01M6CBMPZ4D25CMDJFC79GQ3DC":3,"skill-same:01M6CBMPZ4D25CMDJFC79GQ3DC":57},{"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":31,"linkOnly":32,"markdownContent":33,"markdownBody":34,"frontMatter":35,"refName":36,"commitSha":37,"skillDirectory":38,"forksCount":14,"repositoryHtmlUrl":39,"repositoryHomepageUrl":6,"ownerHtmlUrl":40,"ownerBlogUrl":6,"ownerDescription":41,"updatedAt":42,"install":43,"resources":56},"01M6CBMPZ4D25CMDJFC79GQ3DC","46-purchase-decisions",null,"Record fixed decisions for the purchase backend and client-server contract. Use when checking canonical callable names, request and response fields, idempotency keys, entitlement projection rules, and other non-negotiated purchase design decisions.","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\u002F46-purchase-decisions\u002FSKILL.md",[18],{"categoryNo":19,"categoryName":20,"taxonomyType":21,"taxonomyTypeDesc":22},"01JBSEEDCAT000000000000010","后端与接口","SCENARIO","使用场景",[24,25,26,27,28,29,30],"decisions","46 purchase decisions","design","server","Rules","use","backend","2026-11-10 10:12:30",false,"---\nname: 46-purchase-decisions\ndescription: Record fixed decisions for the purchase backend and client-server contract. Use when checking canonical callable names, request and response fields, idempotency keys, entitlement projection rules, and other non-negotiated purchase design decisions.\n---\n\n# 46-purchase-decisions — Purchase Verification Decisions (Firebase Callable)\n\n\nStatus: ACTIVE\nAppliesTo: v10\n\n\n## 목적\n\n\nFirebase Callable 기반 결제 검증 구현이 \"안정적으로 개발 관리\"되도록,\n레포\u002F서버\u002F스토어검증\u002F멱등키\u002F시크릿\u002FCallable 계약\u002F지급 포맷의\n**결정사항을 단일 정본으로 고정**한다.\n\n이 문서의 값이 바뀌면:\n- 서버(Firebase Functions) 코드\n- 클라(Unity PurchaseManager)\n- Firestore schema\u002Frules\n\n이 함께 변경되어야 한다.\n\n\n---\n\n\n## A. Repo 구조 (결정)\n\n\n- Functions 프로젝트 위치: `{repoRoot}\u002Ffunctions`\n- Firestore rules 위치: `{repoRoot}\u002Ffirestore.rules`\n- Firebase 설정 파일 커밋: `firebase.json`, `.firebaserc` **레포에 포함(커밋)**\n\n\n---\n\n\n## B. Callable 계약 (결정)\n\n\n- Callable 이름(고정):\n  - `verifyPurchase`\n  - `ackPurchaseClientGrant`\n  - `ackPurchaseStoreConfirm`\n  - `getEntitlements`\n  - `getRecentPurchases30d`\n\n- 인증 정책:\n  - Callable은 `context.auth.uid` 필수(unauthenticated 거부)\n\n- `kind`의 정본 enum은 컨텐츠 레이어 SSOT의 `PurchaseKind`이며, Callable에는 그 string 값을 그대로 보낸다.\n\n- 요청 스키마(고정 키):\n  - `storeKey` (`\"apple\" | \"google\"`)\n  - `internal_product_id` (string)\n  - `kind` (`\"Consumable\" | \"Rental\" | \"Subscription\" | \"SeasonPass\"`) (=`PurchaseKind` string)\n  - `payload` (string, Unity IAP receipt raw)\n\n- 응답 스키마(고정 키):\n  - `resultStatus` (`GRANTED | ALREADY_GRANTED | REJECTED | PENDING | REVOKED | REFUNDED`)\n  - `purchaseId` (string, verify 멱등키)\n  - `verifyStatus` (`GRANTED | REJECTED | PENDING | REVOKED | REFUNDED`) — Firestore purchase field와 정합\n  - `clientGrantStatus` (`PENDING | APPLIED_ACKED | FAILED_REPORTED`) — 클라이언트 로컬 지급 결과 보고 상태\n  - `storeConfirmStatus` (`PENDING | CONFIRMED`) — 스토어 Confirm 처리 상태\n  - `grants` (array)\n  - `entitlementsSnapshot` (optional)\n\n- `ackPurchaseClientGrant` 요청\u002F응답(고정 키):\n  - 요청: `purchaseId`, `clientGrantStatus` (`APPLIED_ACKED | FAILED_REPORTED`)\n  - 응답: `purchaseId`, `verifyStatus`, `clientGrantStatus`\n\n- `ackPurchaseStoreConfirm` 요청\u002F응답(고정 키):\n  - 요청: `purchaseId`\n  - 응답: `purchaseId`, `verifyStatus`, `clientGrantStatus`, `storeConfirmStatus`\n\n- 클라이언트 구매 상태 전이 순서(결정):\n  - 계정\u002FSKU 잠김(`already owned`) 방지를 위해 `ConfirmPurchase` + `ackPurchaseStoreConfirm`를 로컬 지급\u002F`ackPurchaseClientGrant`보다 앞에 둔다.\n  - `RetryInterruptedPurchaseAsync()`는 새 구매를 시작하지 않고 `CurrentPurchase` 상태 전이를 재개한다.\n\n- getEntitlements 응답 스냅샷 키(고정):\n  - `ownedSeasonPasses` (string[])\n  - `rentals` (object map; `internal_product_id -> expiresAtServerUtcMs`)\n  - `currencyBalances` (object map)\n  - `serverNowUtcMs` (number)\n\n> NOTE:\n> `Rental` 복원 정본은 서버 `rentals` projection이다.\n> `SeasonPass` 복원 정본은 서버 `ownedSeasonPasses` projection이다.\n> `noAds`는 서버가 아니라 클라이언트 게임 로직 상태로만 관리한다.\n> game logic cache(`noAdsExpireAtClientUtcMs`, `seasonPassOwnership` 등)는 현재 PurchaseStorage에 포함되지 않으며, 향후 필요 시 추가한다.\n\n\n---\n\n\n## C. 멱등키 규칙 (결정)\n\n\n- `purchaseId = \"{storeKey}_{storePurchaseId}\"` 를 Firestore 문서 ID로 사용한다.\n- 동일 `purchaseId` 재요청 시:\n  - 이미 지급 완료면 `ALREADY_GRANTED` 반환(중복 지급 금지)\n  - 응답에 `clientGrantStatus`를 포함해 클라 로컬 지급 복구 가능 여부를 전달한다\n\n### C1. Firestore purchase 상태 필드 명명 (결정)\n\n- `purchases\u002F{purchaseId}.status` → legacy compatibility field (temporary)\n- 정본 필드:\n  - `verifyStatus`\n  - `clientGrantStatus`\n  - `storeConfirmStatus`\n\n\n---\n\n\n## D. Google Play 검증 방식 (결정)\n\n\n- 서버 검증 방식: Google Play Developer API(androidpublisher v3)\n- storePurchaseId 규칙(고정): `purchaseToken`\n- 제품\u002F구독 분기:\n  - `kind == Subscription` → `purchases.subscriptions.get`\n  - `kind == Consumable` \u002F `Rental` \u002F `SeasonPass` → `purchases.products.get` (one-time 검증 경로)\n\n\n---\n\n\n## E. Apple 검증 방식 (결정)\n\n\n- 서버 검증 방식: `verifyReceipt` (Apple receipt validation)\n- storePurchaseId 규칙(고정): `transaction_id`\n- sandbox 재시도 규칙:\n  - status `21007`이면 sandbox endpoint로 재시도\n\n\n---\n\n\n## F. Grants \u002F Entitlements 최소 정책 (결정)\n\n\n- 초기 구현 단계에서는 `grants`는 \"빈 배열\"을 허용한다.\n- 단, `resultStatus`가 `GRANTED\u002FALREADY_GRANTED`일 때는\n  `entitlementsSnapshot`을 항상 반환하도록 한다(클라 동기화 안정성).\n\n> NOTE: 추후 42 스킬(Grants\u002FEntitlements 정본)이 완성되면,\n> 이 문서의 최소 정책을 42의 규칙으로 교체한다.\n\n### F1. 상품 종류별 구매 제한 정책 (결정)\n\n- `Consumable`: 반복 구매 허용\n- `Rental`: 반복 구매 허용 (30일 재구매 제한 없음)\n- `Subscription`: 스토어 검증\u002F상태 기준 (별도 서버 상태 계산)\n- `SeasonPass`: 동일 `internal_product_id` 1회만 허용 (기존 구매 기록 있으면 `REJECTED`)\n\n### F1-1. SeasonPass \u002F Rental 복원용 서버 projection (결정)\n\n- `SeasonPass` 복원용 정본 정보는 서버(Firestore) `ownedSeasonPasses`(`seasonPassId` 목록)를 사용한다.\n- `PurchaseManager`는 SeasonPass 구매 시 REWARD table의 `PASS.Id`를 `seasonPassId`로 서버에 전달한다.\n- `Rental` 복원용 정본 정보는 서버(Firestore) `rentals` map(`rentalId -> expiresAtServerUtcMs`)을 사용한다.\n- `PurchaseManager`는 `noAds`를 별도 구매 복원 타입으로 취급하지 않는다. (`noAds` 해석\u002F적용은 게임 로직 영역)\n\n### F1-2. Rental 만료일 계산 정책 (결정)\n\n- 기준 시간은 반드시 서버 시간(UTC)이다.\n- 재구매 시 만료일 계산은 \"연장\" 방식으로 고정한다:\n  - `newExpiry = max(existingExpiry, serverNow) + 30일`\n- 의미:\n  - 만료 전 재구매: 기존 만료일 + 30일\n  - 만료 후 재구매: 현재 서버시간 + 30일\n- 동일 `purchaseId` 멱등 재시도(`ALREADY_GRANTED`)에서는 만료일을 다시 증가시키지 않는다.\n\n\n---\n\n\n## F2. storePurchasedAt \u002F 최근 30일 조회 (결정)\n\n\n- \"최근 30일 구매 내역\"의 기준 시각은 `storePurchasedAt`(영수증 날짜)이다.\n- 서버 기준 `now`로 threshold(`now − 30일`)를 계산하며, 클라이언트\u002F디바이스 시간은 사용 금지.\n- `kind` 값은 PurchaseKind SSOT(PascalCase) 기준이며, 호출 시 파라미터로 전달한다.\n- Callable 이름은 `getRecentPurchases30d`로 고정한다.\n- `storePurchasedAt` 값은 스토어 검증 응답에서 추출한 구매 시각이며, 서버에서만 생성한다:\n  - Google: `purchaseTimeMillis` (products) \u002F `startTimeMillis` (subscriptions)\n  - Apple: `purchase_date_ms` (in_app 트랜잭션 중 `purchase_date_ms` 최댓값)\n- `storePurchasedAt`(영수증 날짜)은 필수이며, 누락 시 serverTimestamp로 대체하지 않는다.\n  - `purchasedAtMs`를 확보하지 못하면 `verifyPurchase`는 REJECTED 처리한다.\n- 최신(latest) 정의:\n  - 최신 = `storePurchasedAt`가 가장 큰 항목\n  - 동률이면 문서 ID(desc)로 tie-break\n- `PurchaseManager`는 page 없이 최신 1건만 필요하므로:\n  - `getRecentPurchases30d`를 `kind=\"Consumable\"` 또는 `kind=\"Rental\"`, `pageSize=1`로 호출하고 `items[0]`만 사용한다.\n- `getRecentPurchases30d` 페이지네이션:\n  - `nextCursor`는 `\"storePurchasedAtMs|docId\"` 문자열 토큰 형식이다.\n  - `storePurchasedAtMs`는 `storePurchasedAt`의 `toMillis` 값이다.\n\n\n---\n\n\n## G. 시크릿\u002F운영 결정 (결정)\n\n\n- Node 런타임: Node 20\n- 시크릿 키 이름(고정):\n  - `GOOGLE_APPLICATION_CREDENTIALS_JSON`\n  - `APPLE_SHARED_SECRET`\n- 리전: `asia-northeast3` (Seoul)\n\n*(서버 리전 고정 필요 시 적용)*\n\n\n---\n\n\n## H. 관련 정본 링크\n\n\n- Purchase SSOT: `..\u002F03-ssot\u002FSKILL.md`\n- Repo setup(11): `..\u002F11-purchase-repo-firebase-functions-setup\u002FSKILL.md`\n- Backend(40): `..\u002F40-purchase-backend-firebase\u002FSKILL.md`\n- Store verification(41): `..\u002F41-purchase-store-verification\u002FSKILL.md`\n- Grants(42): `..\u002F42-purchase-entitlements-grants\u002FSKILL.md`\n- Client integration(43): `..\u002F43-purchase-client-server-integration\u002FSKILL.md`\n\n\n---\n\n\n## DoD\n\n\nHard (must be 0)\n- [ ] Functions 위치 \u002F rules 위치 \u002F 설정파일 커밋 여부가 단일 결정으로 고정돼 있다.\n- [ ] Callable 이름\u002F스키마\u002F인증 정책이 단일 결정으로 고정돼 있다.\n- [ ] storePurchaseId 규칙(Apple\u002FGoogle)과 purchaseId 멱등 규칙이 고정돼 있다.\n- [ ] 시크릿 키 이름\u002FNode 런타임이 고정돼 있다.\n\nSoft\n- [ ] 42 스킬이 확정되면, grants\u002Fentitlements 최소 정책을 교체하는 작업 항목을 별도 이슈로 등록\n","# 46-purchase-decisions — Purchase Verification Decisions (Firebase Callable)\n\n\nStatus: ACTIVE\nAppliesTo: v10\n\n\n## 목적\n\n\nFirebase Callable 기반 결제 검증 구현이 \"안정적으로 개발 관리\"되도록,\n레포\u002F서버\u002F스토어검증\u002F멱등키\u002F시크릿\u002FCallable 계약\u002F지급 포맷의\n**결정사항을 단일 정본으로 고정**한다.\n\n이 문서의 값이 바뀌면:\n- 서버(Firebase Functions) 코드\n- 클라(Unity PurchaseManager)\n- Firestore schema\u002Frules\n\n이 함께 변경되어야 한다.\n\n\n---\n\n\n## A. Repo 구조 (결정)\n\n\n- Functions 프로젝트 위치: `{repoRoot}\u002Ffunctions`\n- Firestore rules 위치: `{repoRoot}\u002Ffirestore.rules`\n- Firebase 설정 파일 커밋: `firebase.json`, `.firebaserc` **레포에 포함(커밋)**\n\n\n---\n\n\n## B. Callable 계약 (결정)\n\n\n- Callable 이름(고정):\n  - `verifyPurchase`\n  - `ackPurchaseClientGrant`\n  - `ackPurchaseStoreConfirm`\n  - `getEntitlements`\n  - `getRecentPurchases30d`\n\n- 인증 정책:\n  - Callable은 `context.auth.uid` 필수(unauthenticated 거부)\n\n- `kind`의 정본 enum은 컨텐츠 레이어 SSOT의 `PurchaseKind`이며, Callable에는 그 string 값을 그대로 보낸다.\n\n- 요청 스키마(고정 키):\n  - `storeKey` (`\"apple\" | \"google\"`)\n  - `internal_product_id` (string)\n  - `kind` (`\"Consumable\" | \"Rental\" | \"Subscription\" | \"SeasonPass\"`) (=`PurchaseKind` string)\n  - `payload` (string, Unity IAP receipt raw)\n\n- 응답 스키마(고정 키):\n  - `resultStatus` (`GRANTED | ALREADY_GRANTED | REJECTED | PENDING | REVOKED | REFUNDED`)\n  - `purchaseId` (string, verify 멱등키)\n  - `verifyStatus` (`GRANTED | REJECTED | PENDING | REVOKED | REFUNDED`) — Firestore purchase field와 정합\n  - `clientGrantStatus` (`PENDING | APPLIED_ACKED | FAILED_REPORTED`) — 클라이언트 로컬 지급 결과 보고 상태\n  - `storeConfirmStatus` (`PENDING | CONFIRMED`) — 스토어 Confirm 처리 상태\n  - `grants` (array)\n  - `entitlementsSnapshot` (optional)\n\n- `ackPurchaseClientGrant` 요청\u002F응답(고정 키):\n  - 요청: `purchaseId`, `clientGrantStatus` (`APPLIED_ACKED | FAILED_REPORTED`)\n  - 응답: `purchaseId`, `verifyStatus`, `clientGrantStatus`\n\n- `ackPurchaseStoreConfirm` 요청\u002F응답(고정 키):\n  - 요청: `purchaseId`\n  - 응답: `purchaseId`, `verifyStatus`, `clientGrantStatus`, `storeConfirmStatus`\n\n- 클라이언트 구매 상태 전이 순서(결정):\n  - 계정\u002FSKU 잠김(`already owned`) 방지를 위해 `ConfirmPurchase` + `ackPurchaseStoreConfirm`를 로컬 지급\u002F`ackPurchaseClientGrant`보다 앞에 둔다.\n  - `RetryInterruptedPurchaseAsync()`는 새 구매를 시작하지 않고 `CurrentPurchase` 상태 전이를 재개한다.\n\n- getEntitlements 응답 스냅샷 키(고정):\n  - `ownedSeasonPasses` (string[])\n  - `rentals` (object map; `internal_product_id -> expiresAtServerUtcMs`)\n  - `currencyBalances` (object map)\n  - `serverNowUtcMs` (number)\n\n> NOTE:\n> `Rental` 복원 정본은 서버 `rentals` projection이다.\n> `SeasonPass` 복원 정본은 서버 `ownedSeasonPasses` projection이다.\n> `noAds`는 서버가 아니라 클라이언트 게임 로직 상태로만 관리한다.\n> game logic cache(`noAdsExpireAtClientUtcMs`, `seasonPassOwnership` 등)는 현재 PurchaseStorage에 포함되지 않으며, 향후 필요 시 추가한다.\n\n\n---\n\n\n## C. 멱등키 규칙 (결정)\n\n\n- `purchaseId = \"{storeKey}_{storePurchaseId}\"` 를 Firestore 문서 ID로 사용한다.\n- 동일 `purchaseId` 재요청 시:\n  - 이미 지급 완료면 `ALREADY_GRANTED` 반환(중복 지급 금지)\n  - 응답에 `clientGrantStatus`를 포함해 클라 로컬 지급 복구 가능 여부를 전달한다\n\n### C1. Firestore purchase 상태 필드 명명 (결정)\n\n- `purchases\u002F{purchaseId}.status` → legacy compatibility field (temporary)\n- 정본 필드:\n  - `verifyStatus`\n  - `clientGrantStatus`\n  - `storeConfirmStatus`\n\n\n---\n\n\n## D. Google Play 검증 방식 (결정)\n\n\n- 서버 검증 방식: Google Play Developer API(androidpublisher v3)\n- storePurchaseId 규칙(고정): `purchaseToken`\n- 제품\u002F구독 분기:\n  - `kind == Subscription` → `purchases.subscriptions.get`\n  - `kind == Consumable` \u002F `Rental` \u002F `SeasonPass` → `purchases.products.get` (one-time 검증 경로)\n\n\n---\n\n\n## E. Apple 검증 방식 (결정)\n\n\n- 서버 검증 방식: `verifyReceipt` (Apple receipt validation)\n- storePurchaseId 규칙(고정): `transaction_id`\n- sandbox 재시도 규칙:\n  - status `21007`이면 sandbox endpoint로 재시도\n\n\n---\n\n\n## F. Grants \u002F Entitlements 최소 정책 (결정)\n\n\n- 초기 구현 단계에서는 `grants`는 \"빈 배열\"을 허용한다.\n- 단, `resultStatus`가 `GRANTED\u002FALREADY_GRANTED`일 때는\n  `entitlementsSnapshot`을 항상 반환하도록 한다(클라 동기화 안정성).\n\n> NOTE: 추후 42 스킬(Grants\u002FEntitlements 정본)이 완성되면,\n> 이 문서의 최소 정책을 42의 규칙으로 교체한다.\n\n### F1. 상품 종류별 구매 제한 정책 (결정)\n\n- `Consumable`: 반복 구매 허용\n- `Rental`: 반복 구매 허용 (30일 재구매 제한 없음)\n- `Subscription`: 스토어 검증\u002F상태 기준 (별도 서버 상태 계산)\n- `SeasonPass`: 동일 `internal_product_id` 1회만 허용 (기존 구매 기록 있으면 `REJECTED`)\n\n### F1-1. SeasonPass \u002F Rental 복원용 서버 projection (결정)\n\n- `SeasonPass` 복원용 정본 정보는 서버(Firestore) `ownedSeasonPasses`(`seasonPassId` 목록)를 사용한다.\n- `PurchaseManager`는 SeasonPass 구매 시 REWARD table의 `PASS.Id`를 `seasonPassId`로 서버에 전달한다.\n- `Rental` 복원용 정본 정보는 서버(Firestore) `rentals` map(`rentalId -> expiresAtServerUtcMs`)을 사용한다.\n- `PurchaseManager`는 `noAds`를 별도 구매 복원 타입으로 취급하지 않는다. (`noAds` 해석\u002F적용은 게임 로직 영역)\n\n### F1-2. Rental 만료일 계산 정책 (결정)\n\n- 기준 시간은 반드시 서버 시간(UTC)이다.\n- 재구매 시 만료일 계산은 \"연장\" 방식으로 고정한다:\n  - `newExpiry = max(existingExpiry, serverNow) + 30일`\n- 의미:\n  - 만료 전 재구매: 기존 만료일 + 30일\n  - 만료 후 재구매: 현재 서버시간 + 30일\n- 동일 `purchaseId` 멱등 재시도(`ALREADY_GRANTED`)에서는 만료일을 다시 증가시키지 않는다.\n\n\n---\n\n\n## F2. storePurchasedAt \u002F 최근 30일 조회 (결정)\n\n\n- \"최근 30일 구매 내역\"의 기준 시각은 `storePurchasedAt`(영수증 날짜)이다.\n- 서버 기준 `now`로 threshold(`now − 30일`)를 계산하며, 클라이언트\u002F디바이스 시간은 사용 금지.\n- `kind` 값은 PurchaseKind SSOT(PascalCase) 기준이며, 호출 시 파라미터로 전달한다.\n- Callable 이름은 `getRecentPurchases30d`로 고정한다.\n- `storePurchasedAt` 값은 스토어 검증 응답에서 추출한 구매 시각이며, 서버에서만 생성한다:\n  - Google: `purchaseTimeMillis` (products) \u002F `startTimeMillis` (subscriptions)\n  - Apple: `purchase_date_ms` (in_app 트랜잭션 중 `purchase_date_ms` 최댓값)\n- `storePurchasedAt`(영수증 날짜)은 필수이며, 누락 시 serverTimestamp로 대체하지 않는다.\n  - `purchasedAtMs`를 확보하지 못하면 `verifyPurchase`는 REJECTED 처리한다.\n- 최신(latest) 정의:\n  - 최신 = `storePurchasedAt`가 가장 큰 항목\n  - 동률이면 문서 ID(desc)로 tie-break\n- `PurchaseManager`는 page 없이 최신 1건만 필요하므로:\n  - `getRecentPurchases30d`를 `kind=\"Consumable\"` 또는 `kind=\"Rental\"`, `pageSize=1`로 호출하고 `items[0]`만 사용한다.\n- `getRecentPurchases30d` 페이지네이션:\n  - `nextCursor`는 `\"storePurchasedAtMs|docId\"` 문자열 토큰 형식이다.\n  - `storePurchasedAtMs`는 `storePurchasedAt`의 `toMillis` 값이다.\n\n\n---\n\n\n## G. 시크릿\u002F운영 결정 (결정)\n\n\n- Node 런타임: Node 20\n- 시크릿 키 이름(고정):\n  - `GOOGLE_APPLICATION_CREDENTIALS_JSON`\n  - `APPLE_SHARED_SECRET`\n- 리전: `asia-northeast3` (Seoul)\n\n*(서버 리전 고정 필요 시 적용)*\n\n\n---\n\n\n## H. 관련 정본 링크\n\n\n- Purchase SSOT: `..\u002F03-ssot\u002FSKILL.md`\n- Repo setup(11): `..\u002F11-purchase-repo-firebase-functions-setup\u002FSKILL.md`\n- Backend(40): `..\u002F40-purchase-backend-firebase\u002FSKILL.md`\n- Store verification(41): `..\u002F41-purchase-store-verification\u002FSKILL.md`\n- Grants(42): `..\u002F42-purchase-entitlements-grants\u002FSKILL.md`\n- Client integration(43): `..\u002F43-purchase-client-server-integration\u002FSKILL.md`\n\n\n---\n\n\n## DoD\n\n\nHard (must be 0)\n- [ ] Functions 위치 \u002F rules 위치 \u002F 설정파일 커밋 여부가 단일 결정으로 고정돼 있다.\n- [ ] Callable 이름\u002F스키마\u002F인증 정책이 단일 결정으로 고정돼 있다.\n- [ ] storePurchaseId 규칙(Apple\u002FGoogle)과 purchaseId 멱등 규칙이 고정돼 있다.\n- [ ] 시크릿 키 이름\u002FNode 런타임이 고정돼 있다.\n\nSoft\n- [ ] 42 스킬이 확정되면, grants\u002Fentitlements 최소 정책을 교체하는 작업 항목을 별도 이슈로 등록\n",{"name":5,"description":7},"main","8cb5bd877a3a7004867d2f55697cb9e4ff2f1734","skills\u002Fdevian-unity\u002F50-mobile-package\u002F30-purchase-system\u002F46-purchase-decisions","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":44,"prompt":54,"zipAvailable":55},[45,48,51],{"tool":46,"command":47},"npx","npx skills add devwinsoft\u002Fdevian --skill 46-purchase-decisions",{"tool":49,"command":50},"bunx","bunx skills add devwinsoft\u002Fdevian --skill 46-purchase-decisions",{"tool":52,"command":53},"pnpm","pnpm dlx skills add devwinsoft\u002Fdevian --skill 46-purchase-decisions","安装技能，技能地址：https:\u002F\u002Fgithub.com\u002Fdevwinsoft\u002Fdevian\u002Fblob\u002Fmain\u002Fskills\u002Fdevian-unity\u002F50-mobile-package\u002F30-purchase-system\u002F46-purchase-decisions\u002FSKILL.md",true,[],[58,64,72,80,89,96],{"skillNo":59,"name":60,"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":61,"categories":62,"keywords":63,"publishedAt":31},"01M6CBX3K2JJV38ANHVGCAT8A0","devian","https:\u002F\u002Fgithub.com\u002Fdevwinsoft\u002Fdevian\u002Fblob\u002Fmain\u002Fskills\u002Fdevian\u002FSKILL.md",[],[60],{"skillNo":65,"name":66,"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":67,"categories":68,"keywords":69,"publishedAt":31},"01M6CBX210BFMM8AMN770J9WY8","90-project-archive","https:\u002F\u002Fgithub.com\u002Fdevwinsoft\u002Fdevian\u002Fblob\u002Fmain\u002Fskills\u002Fdevian\u002F80-tools\u002F90-project-archive\u002FSKILL.md",[],[70,71],"archive","90 project archive",{"skillNo":73,"name":74,"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":75,"categories":76,"keywords":77,"publishedAt":31},"01M6CBWZSGRR9R3YJZB3177667","30-env-management","https:\u002F\u002Fgithub.com\u002Fdevwinsoft\u002Fdevian\u002Fblob\u002Fmain\u002Fskills\u002Fdevian\u002F80-tools\u002F30-env-management\u002FSKILL.md",[],[78,79],"management","30 env management",{"skillNo":81,"name":82,"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":83,"categories":84,"keywords":85,"publishedAt":31},"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",[],[86,87,88],"push","page","19 page push send",{"skillNo":90,"name":91,"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":92,"categories":93,"keywords":94,"publishedAt":31},"01M6CBWWRQ05A7604GHJKTPJTF","18-page-savedata","https:\u002F\u002Fgithub.com\u002Fdevwinsoft\u002Fdevian\u002Fblob\u002Fmain\u002Fskills\u002Fdevian\u002F80-tools\u002F20-operation\u002F18-page-savedata\u002FSKILL.md",[],[87,95],"18 page savedata",{"skillNo":97,"name":98,"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":99,"categories":100,"keywords":101,"publishedAt":31},"01M6CBWVH4S2VF1V7B2GBTESCT","17-page-deobfuscate","https:\u002F\u002Fgithub.com\u002Fdevwinsoft\u002Fdevian\u002Fblob\u002Fmain\u002Fskills\u002Fdevian\u002F80-tools\u002F20-operation\u002F17-page-deobfuscate\u002FSKILL.md",[],[87,102],"17 page deobfuscate"]