46-purchase-decisions
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.
- License 许可
- MIT
- 最近更新
- 2026-04-12
- Stars
- ★ 0
- Forks
- 0
01 前言 Front Matter
- name
- 46-purchase-decisions
- description
- 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.
03 SKILL.md 正文
46-purchase-decisions — Purchase Verification Decisions (Firebase Callable)
Status: ACTIVE AppliesTo: v10
목적
Firebase Callable 기반 결제 검증 구현이 "안정적으로 개발 관리"되도록, 레포/서버/스토어검증/멱등키/시크릿/Callable 계약/지급 포맷의 결정사항을 단일 정본으로 고정한다.
이 문서의 값이 바뀌면:
- 서버(Firebase Functions) 코드
- 클라(Unity PurchaseManager)
- Firestore schema/rules
이 함께 변경되어야 한다.
A. Repo 구조 (결정)
- Functions 프로젝트 위치:
{repoRoot}/functions - Firestore rules 위치:
{repoRoot}/firestore.rules - Firebase 설정 파일 커밋:
firebase.json,.firebaserc레포에 포함(커밋)
B. Callable 계약 (결정)
-
Callable 이름(고정):
verifyPurchaseackPurchaseClientGrantackPurchaseStoreConfirmgetEntitlementsgetRecentPurchases30d
-
인증 정책:
- Callable은
context.auth.uid필수(unauthenticated 거부)
- Callable은
-
kind의 정본 enum은 컨텐츠 레이어 SSOT의PurchaseKind이며, Callable에는 그 string 값을 그대로 보낸다. -
요청 스키마(고정 키):
storeKey("apple" | "google")internal_product_id(string)kind("Consumable" | "Rental" | "Subscription" | "SeasonPass") (=PurchaseKindstring)payload(string, Unity IAP receipt raw)
-
응답 스키마(고정 키):
resultStatus(GRANTED | ALREADY_GRANTED | REJECTED | PENDING | REVOKED | REFUNDED)purchaseId(string, verify 멱등키)verifyStatus(GRANTED | REJECTED | PENDING | REVOKED | REFUNDED) — Firestore purchase field와 정합clientGrantStatus(PENDING | APPLIED_ACKED | FAILED_REPORTED) — 클라이언트 로컬 지급 결과 보고 상태storeConfirmStatus(PENDING | CONFIRMED) — 스토어 Confirm 처리 상태grants(array)entitlementsSnapshot(optional)
-
ackPurchaseClientGrant요청/응답(고정 키):- 요청:
purchaseId,clientGrantStatus(APPLIED_ACKED | FAILED_REPORTED) - 응답:
purchaseId,verifyStatus,clientGrantStatus
- 요청:
-
ackPurchaseStoreConfirm요청/응답(고정 키):- 요청:
purchaseId - 응답:
purchaseId,verifyStatus,clientGrantStatus,storeConfirmStatus
- 요청:
-
클라이언트 구매 상태 전이 순서(결정):
- 계정/SKU 잠김(
already owned) 방지를 위해ConfirmPurchase+ackPurchaseStoreConfirm를 로컬 지급/ackPurchaseClientGrant보다 앞에 둔다. RetryInterruptedPurchaseAsync()는 새 구매를 시작하지 않고CurrentPurchase상태 전이를 재개한다.
- 계정/SKU 잠김(
-
getEntitlements 응답 스냅샷 키(고정):
ownedSeasonPasses(string[])rentals(object map;internal_product_id -> expiresAtServerUtcMs)currencyBalances(object map)serverNowUtcMs(number)
NOTE:
Rental복원 정본은 서버rentalsprojection이다.SeasonPass복원 정본은 서버ownedSeasonPassesprojection이다.noAds는 서버가 아니라 클라이언트 게임 로직 상태로만 관리한다. game logic cache(noAdsExpireAtClientUtcMs,seasonPassOwnership등)는 현재 PurchaseStorage에 포함되지 않으며, 향후 필요 시 추가한다.
C. 멱등키 규칙 (결정)
purchaseId = "{storeKey}_{storePurchaseId}"를 Firestore 문서 ID로 사용한다.- 동일
purchaseId재요청 시:- 이미 지급 완료면
ALREADY_GRANTED반환(중복 지급 금지) - 응답에
clientGrantStatus를 포함해 클라 로컬 지급 복구 가능 여부를 전달한다
- 이미 지급 완료면
C1. Firestore purchase 상태 필드 명명 (결정)
purchases/{purchaseId}.status→ legacy compatibility field (temporary)- 정본 필드:
verifyStatusclientGrantStatusstoreConfirmStatus
D. Google Play 검증 방식 (결정)
- 서버 검증 방식: Google Play Developer API(androidpublisher v3)
- storePurchaseId 규칙(고정):
purchaseToken - 제품/구독 분기:
kind == Subscription→purchases.subscriptions.getkind == Consumable/Rental/SeasonPass→purchases.products.get(one-time 검증 경로)
E. Apple 검증 방식 (결정)
- 서버 검증 방식:
verifyReceipt(Apple receipt validation) - storePurchaseId 규칙(고정):
transaction_id - sandbox 재시도 규칙:
- status
21007이면 sandbox endpoint로 재시도
- status
F. Grants / Entitlements 최소 정책 (결정)
- 초기 구현 단계에서는
grants는 "빈 배열"을 허용한다. - 단,
resultStatus가GRANTED/ALREADY_GRANTED일 때는entitlementsSnapshot을 항상 반환하도록 한다(클라 동기화 안정성).
NOTE: 추후 42 스킬(Grants/Entitlements 정본)이 완성되면, 이 문서의 최소 정책을 42의 규칙으로 교체한다.
F1. 상품 종류별 구매 제한 정책 (결정)
Consumable: 반복 구매 허용Rental: 반복 구매 허용 (30일 재구매 제한 없음)Subscription: 스토어 검증/상태 기준 (별도 서버 상태 계산)SeasonPass: 동일internal_product_id1회만 허용 (기존 구매 기록 있으면REJECTED)
F1-1. SeasonPass / Rental 복원용 서버 projection (결정)
SeasonPass복원용 정본 정보는 서버(Firestore)ownedSeasonPasses(seasonPassId목록)를 사용한다.PurchaseManager는 SeasonPass 구매 시 REWARD table의PASS.Id를seasonPassId로 서버에 전달한다.Rental복원용 정본 정보는 서버(Firestore)rentalsmap(rentalId -> expiresAtServerUtcMs)을 사용한다.PurchaseManager는noAds를 별도 구매 복원 타입으로 취급하지 않는다. (noAds해석/적용은 게임 로직 영역)
F1-2. Rental 만료일 계산 정책 (결정)
- 기준 시간은 반드시 서버 시간(UTC)이다.
- 재구매 시 만료일 계산은 "연장" 방식으로 고정한다:
newExpiry = max(existingExpiry, serverNow) + 30일
- 의미:
- 만료 전 재구매: 기존 만료일 + 30일
- 만료 후 재구매: 현재 서버시간 + 30일
- 동일
purchaseId멱등 재시도(ALREADY_GRANTED)에서는 만료일을 다시 증가시키지 않는다.
F2. storePurchasedAt / 최근 30일 조회 (결정)
- "최근 30일 구매 내역"의 기준 시각은
storePurchasedAt(영수증 날짜)이다. - 서버 기준
now로 threshold(now − 30일)를 계산하며, 클라이언트/디바이스 시간은 사용 금지. kind값은 PurchaseKind SSOT(PascalCase) 기준이며, 호출 시 파라미터로 전달한다.- Callable 이름은
getRecentPurchases30d로 고정한다. storePurchasedAt값은 스토어 검증 응답에서 추출한 구매 시각이며, 서버에서만 생성한다:- Google:
purchaseTimeMillis(products) /startTimeMillis(subscriptions) - Apple:
purchase_date_ms(in_app 트랜잭션 중purchase_date_ms최댓값)
- Google:
storePurchasedAt(영수증 날짜)은 필수이며, 누락 시 serverTimestamp로 대체하지 않는다.purchasedAtMs를 확보하지 못하면verifyPurchase는 REJECTED 처리한다.
- 최신(latest) 정의:
- 최신 =
storePurchasedAt가 가장 큰 항목 - 동률이면 문서 ID(desc)로 tie-break
- 최신 =
PurchaseManager는 page 없이 최신 1건만 필요하므로:getRecentPurchases30d를kind="Consumable"또는kind="Rental",pageSize=1로 호출하고items[0]만 사용한다.
getRecentPurchases30d페이지네이션:nextCursor는"storePurchasedAtMs|docId"문자열 토큰 형식이다.storePurchasedAtMs는storePurchasedAt의toMillis값이다.
G. 시크릿/운영 결정 (결정)
- Node 런타임: Node 20
- 시크릿 키 이름(고정):
GOOGLE_APPLICATION_CREDENTIALS_JSONAPPLE_SHARED_SECRET
- 리전:
asia-northeast3(Seoul)
(서버 리전 고정 필요 시 적용)
H. 관련 정본 링크
- Purchase SSOT:
../03-ssot/SKILL.md - Repo setup(11):
../11-purchase-repo-firebase-functions-setup/SKILL.md - Backend(40):
../40-purchase-backend-firebase/SKILL.md - Store verification(41):
../41-purchase-store-verification/SKILL.md - Grants(42):
../42-purchase-entitlements-grants/SKILL.md - Client integration(43):
../43-purchase-client-server-integration/SKILL.md
DoD
Hard (must be 0)
- [ ] Functions 위치 / rules 위치 / 설정파일 커밋 여부가 단일 결정으로 고정돼 있다.
- [ ] Callable 이름/스키마/인증 정책이 단일 결정으로 고정돼 있다.
- [ ] storePurchaseId 규칙(Apple/Google)과 purchaseId 멱등 규칙이 고정돼 있다.
- [ ] 시크릿 키 이름/Node 런타임이 고정돼 있다.
Soft
- [ ] 42 스킬이 확정되면, grants/entitlements 최소 정책을 교체하는 작업 항목을 별도 이슈로 등록