Subscription API (PRO)
엔타이틀먼트의 진실은 서버. 앱은 스토어 거래 토큰을 백엔드로 보내 검증받고, 백엔드가 users.premium_until 를 갱신한다. 상세 도메인 룰: spec/domain-subscription.md.
POST /api/v1/subscription/verify
의도
스토어 구매/복원 후, 또는 앱 실행 refresh 시 — 거래를 스토어 서버 API 로 검증해 구독 활성 여부를 확정하고 premium_until 를 갱신한다. (구매·복원·refresh 공용)
인증
Bearer JWT 필수 (엔타이틀먼트가 userID 에 귀속 — 비로그인 불가).
요청
{
"platform": "ios",
"token": "<App Store: signed transaction(JWS) | Play: purchaseToken>",
"product_id": "dartbrief.pro.monthly"
}| 필드 | 설명 |
|---|---|
platform | ios / android |
token | iOS = StoreKit2 signed transaction(JWS), Android = purchaseToken |
product_id | dartbrief.pro.monthly 또는 dartbrief.max.monthly |
동작
- iOS: App Store Server API 로 거래 검증 + Get Subscription Statuses → 만료시각/상태.
- Android: Play Developer API
purchases.subscriptionsv2.get→ 만료시각/상태. - 활성(active/grace)이면 스토어 검증 응답의 상품 식별자(서버가 신뢰하는 값)로
premium_tier(pro/max) 결정 +premium_until=스토어 만료시각,premium_platform/premium_tx_ref저장. 요청의product_id는 교차검증용일 뿐 티어 결정 근거로 쓰지 않음 — 클라이언트가 PRO 토큰에dartbrief.max.monthly를 실어 보내는 식의 티어 위조 차단. - expired/refunded 면 엔타이틀먼트 회수(
premium_until과거,premium_tier=none).
응답
200 OK
{
"active": true,
"tier": "pro",
"premium_until": "2026-06-24T00:00:00Z",
"product_id": "dartbrief.pro.monthly"
}| 필드 | 설명 |
|---|---|
active | premium_until > now() (grace 포함) |
tier | none / pro / max — 클라이언트가 한도(분석 60/200, 종목 30/100) 표시에 사용 |
premium_until | 구독 만료 시각 RFC3339. 비활성(만료/환불)이면 과거값 또는 null |
product_id | 검증된 상품 |
비활성(만료/환불 등
active:false)이면 엔타이틀먼트 회수(premium_tier=none,premium_until=null) 후{ "active": false, "tier": "none" }.
400 Bad Request
platform(ios/android 외)·token 누락·형식 오류.
401 Unauthorized
JWT 없음.
402 Payment Required
스토어 검증에서 유효하지 않은 거래(위조/미존재) → 권한 부여 안 함.
409 Conflict
검증은 됐으나 알 수 없는 상품(매핑 외 product) → 권한 부여 안 함.
503 Service Unavailable
스토어 검증 클라이언트 미구성(크레덴셜 부재) 또는 일시적 오류 → fail-closed(권한 부여 안 함). 구현 현황: internal/subscription 의 StoreVerifier 가 인터페이스로 추상화돼 있고, App Store Server API(.p8)·Play Developer API(서비스계정) 클라이언트는 크레덴셜 도착 후 주입. 그 전까지 stubVerifier 가 503 반환.
POST /internal/appstore-notifications (Apple ASSN V2)
App Store Server Notifications V2 — Apple 이 갱신/만료/환불 등 이벤트를 이 URL 로 직접 POST.
- 인증: JWT 없음. 본문은 signed payload(JWS) — Apple 공개키로 서명 검증 후 신뢰.
- 처리:
notificationType(DID_RENEW / EXPIRED / DID_FAIL_TO_RENEW(grace) / REFUND / REVOKE 등) → 해당 user 의premium_until갱신/회수. - 응답: 200(수신 확인). 실패 시 Apple 이 재시도.
POST /internal/play-rtdn (Google Play RTDN)
Play Real-time Developer Notifications — Play → GCP Pub/Sub 토픽 → push 구독이 이 URL 로 POST.
- 인증: Pub/Sub push 메시지 검증(OIDC 토큰 or 공유 시크릿). 본문
data는 base64 RTDN payload. - 처리:
subscriptionNotification.notificationType(RENEWED / EXPIRED / IN_GRACE_PERIOD / REVOKED / 환불 등) → Play Developer API 로 상태 재조회 →premium_until갱신/회수. - 응답: 200(ack). 비-200 이면 Pub/Sub 재전송.
- 셋업(사용자 수동): GCP 토픽 생성 + Play 에 발행 권한 + 토픽의 push 구독을 이 URL 로.
웹훅이 즉시 반영,
verify가 누락/지연 보정. 둘 다premium_tier+premium_until단일 진실을 갱신.
엔타이틀먼트 노출
activeTier(none/pro/max) 가 다른 API 응답에 반영된다:
GET /api/v1/quota—weekly_limit이 티어 값(10/60/200)으로 내려옴. 응답에tier(none/pro/max) 포함. (api/quota)GET /api/v1/watchlist/capacity—capacity가 티어 값(3/30/100). (api/watchlist)
구현:
internal/entitlement패키지가 티어→한도 매핑(Tier.WeeklyLimit()/Tier.SlotCapacity())과 활성 판정(ActiveTier=premium_until > now()이면premium_tier)의 단일 진실. migration 00018 (userspremium_*컬럼 +slot_capacity제거).verify엔드포인트/웹훅은 후속 PR.