import { onCall } from 'firebase-functions/v2/https'; import { getFirestore, FieldValue, Timestamp } from 'firebase-admin/firestore'; import { logger } from 'firebase-functions'; import { callerUid, optionalStr, requireRole, str } from '../core/callable.js'; import { txIdOf } from '../core/ids.js'; import { dayKey } from '../core/time.js'; import { dropConfig, dropDocument, publicNickname } from '../fandom/herodrop.js'; import { loadPolicy } from '../core/config.js'; import { ERR, fail } from '../core/errors.js'; import { grantPop, spendPop } from '../ledger/ledger.js'; import { popForProduct, StoreReceiptVerifier, SUBSCRIPTION_PRODUCTS, type Platform, type ReceiptClaim, type ReceiptVerifier, } from './receipt.js'; /** * 결제 — 별첨 1 ⑥ 「영수증 서버검증 통과(위조 차단)」·「환불 결제 건 지급 회수」. * * 세 가지를 지킨다. * 1. **금액은 서버가 정한다.** 클라이언트가 보낸 수량을 쓰지 않고 상품 ID 로 표를 본다. * 2. **멱등키는 스토어 거래 ID 다.** 같은 구매가 두 번 지급되지 않는다. * 3. **환불은 회수한다.** 잔액이 모자라면 음수 잔액을 만들지 않고 회수 대기로 남긴다. */ let verifier: ReceiptVerifier = new StoreReceiptVerifier(); /** 테스트에서 스토어 호출을 대역으로 바꾼다. */ export function setReceiptVerifier(next: ReceiptVerifier): void { verifier = next; } const PLATFORMS: readonly Platform[] = ['ios', 'android']; function platformOf(data: unknown): Platform { const value = str(data, 'platform'); if (!PLATFORMS.includes(value as Platform)) { fail(ERR.payInvalidReceipt, 'platform 은 ios 또는 android 여야 합니다.'); } return value as Platform; } /** 스토어 거래 ID 를 원장 멱등키로 옮긴다 — 설계서 §3.3 의 IAP 키 구성. */ const iapKey = (claim: ReceiptClaim) => `iap:${claim.platform}:${claim.transactionId}`; /** POP 묶음 상품 ID 접두 — 정책 원장 B-02 로 기본 판매 중지(`popPackSalesEnabled`). */ const POP_PACK_PREFIX = 'pop.support.'; const PAY_METHOD: Readonly> = { android: 'Google Play', ios: 'App Store', }; interface HeroGiftProduct { name: string; buyerPop: number; dropPop: number } /** * Hero Gift 상품 Catalog — 정책 원장 B-85. 문서 ID 가 스토어 상품 ID 다. * * Admin `관리 마스터_아이템 관리` 가 `fnAdminWrite` 로 쓴다. 상품을 늘려도 앱을 * 다시 배포하지 않는다(B-40 검증 기준). Catalog 에 없으면 null — 일반 상품 표로 넘어간다. */ async function heroGiftProduct(productId: string): Promise { const snap = await getFirestore().doc(`heroGiftProducts/${productId}`).get(); if (!snap.exists) return null; if (snap.get('active') !== true) { fail(ERR.payInvalidReceipt, '판매가 끝난 상품입니다.'); } const buyerPop = snap.get('buyerPop'); const dropPop = snap.get('dropPop'); if (!Number.isSafeInteger(buyerPop) || buyerPop <= 0 || !Number.isSafeInteger(dropPop) || dropPop <= 0) { // Admin 확정값이 없으면 지급량을 지어내지 않는다(B-84 「Admin 저장값과 정확 일치」). fail(ERR.payVerifyFailed, '상품 지급량이 아직 설정되지 않았습니다.'); } if (buyerPop !== dropPop) { // B-84 — 구매자 기여 POP 과 팬덤 Drop Pool 일반 POP 은 **같은 수량**이다. 어긋난 Catalog // 값으로 지급하지 않는다. Admin 이 고치면 다음 재전송에서 지급된다(재시도 가능한 E-PAY-02). fail(ERR.payVerifyFailed, '상품 지급량이 정책(구매자 = Drop Pool 동일 수량)과 다릅니다.'); } const name = snap.get('name'); return { name: typeof name === 'string' ? name : productId, buyerPop, dropPop }; } /** * 거래 ID 를 한 계정에 묶는다. * * 원장 멱등키는 사용자 아래에 있어서, 같은 영수증을 **다른 계정**이 보내면 그 * 계정 원장에는 키가 없어 한 번 더 지급된다. 거래 ID 의 주인을 전역 문서로 고정한다. */ async function claimTransaction(purchaseId: string, uid: string): Promise { const ref = getFirestore().doc(`iapTransactions/${purchaseId}`); try { await ref.create({ uid, createdAt: FieldValue.serverTimestamp() }); } catch (e) { const code = (e as { code?: number | string }).code; if (code !== 6 && code !== 'already-exists') throw e; if ((await ref.get()).get('uid') !== uid) { fail(ERR.payInvalidReceipt, '다른 계정에서 이미 사용된 결제입니다.'); } } } /** 화면 표시용 값. 앱이 스토어에서 받은 가격·이름이라 **지급 판단에 쓰지 않는다.** */ function displayFields(data: unknown, platform: Platform) { const raw = (data ?? {}) as Record; const price = raw.priceAmount; const currency = raw.currency; const title = raw.title; return { method: PAY_METHOD[platform], ...(typeof price === 'number' && Number.isFinite(price) && price >= 0 ? { priceAmount: price } : {}), ...(typeof currency === 'string' && /^[A-Z]{3}$/.test(currency) ? { currency } : {}), title: typeof title === 'string' ? title.slice(0, 60) : '', }; } /** * `fn-iap-verify` — 구매 검증 후 혜택 지급. * * 상품 ID 로 세 갈래를 가른다. * - 구독(Focus) — POP 없이 구독 상태를 켠다(B-04 광고 제거). * - Hero Gift Catalog — 구매자 기여 POP + 소속 팬덤 Drop Pool(B-84). 둘이 한 트랜잭션. * 알려진 한계: 이 구매가 환불되면 `fn-iap-refund-webhook` 은 구매자 기여 POP 만 회수하고 * **이미 열린 Drop Pool(다른 팬이 받았을 수 있는 일반 POP)은 회수하지 않는다.** * - 그 밖의 상품 표 — 기여 POP. 단 POP 묶음(`pop.support.*`)은 B-02 로 `popPackSalesEnabled` * 가 켜졌을 때만 지급한다(기본 거부 · E-PAY-01). * * 오류 코드 규약(앱이 이것으로 거래를 닫을지 정한다): E-PAY-01 은 재시도해도 결과가 같은 * 거부(판매 중지 · 다른 계정 · 소속 행성 없음), E-PAY-02 는 설정·검증이 준비되면 통과할 수 있는 실패다. * * 지급 전에 구매 기록을 먼저 남긴다. 지급이 실패해도 「검증은 됐는데 지급이 * 안 된 구매」가 추적 가능해야 한다. 앱은 성공 응답을 받은 뒤에만 스토어에 구매 * 완료를 알린다 — 실패하면 다음 실행에서 다시 보낸다. */ export const fnIapVerify = onCall(async (req) => { const uid = callerUid(req); const platform = platformOf(req.data); const receipt = str(req.data, 'receipt'); const claim = await verifier.verify(platform, receipt, { productId: optionalStr(req.data, 'productId'), transactionId: optionalStr(req.data, 'transactionId'), }); if (claim.revoked) { fail(ERR.payVerifyFailed, '이미 취소된 결제입니다.'); } const db = getFirestore(); const purchaseId = `${claim.platform}_${claim.transactionId}`; const purchaseRef = db.doc(`users/${uid}/purchases/${purchaseId}`); await claimTransaction(purchaseId, uid); const existing = await purchaseRef.get(); const priorState = existing.get('state') as string | undefined; if (existing.exists && priorState !== 'verified') { // 재전송(복원 · 앱 재시작 · 응답 유실)은 첫 결과를 돌려준다. // 환불 요청 · 회수 상태를 「지급 완료」로 되돌리지 않는다. return { duplicate: true, state: priorState, granted: (existing.get('amountPop') as number | undefined) ?? 0, txId: (existing.get('txId') as string | undefined) ?? null, }; } const { title, ...display } = displayFields(req.data, platform); const base = { uid, platform: claim.platform, transactionId: claim.transactionId, productId: claim.productId, purchasedAt: Timestamp.fromMillis(claim.purchasedAtMillis), ...display, updatedAt: FieldValue.serverTimestamp(), ...(existing.exists ? {} : { createdAt: FieldValue.serverTimestamp() }), }; if (SUBSCRIPTION_PRODUCTS.includes(claim.productId)) { // 만료 시각이 미래일 때만 활성이다. 스토어가 만료를 주지 않으면 활성으로 보지 않는다. const expiresAt = claim.expiresAtMillis; const active = typeof expiresAt === 'number' && expiresAt > Date.now(); const subRef = db.doc(`users/${uid}/subscription/current`); const current = (await subRef.get()).get('expiresAt'); // 옛 거래(복원 재전송)가 더 늦게 끝나는 최신 구독 상태를 덮지 않는다. const stale = current instanceof Timestamp && (typeof expiresAt !== 'number' || current.toMillis() > expiresAt); const batch = db.batch(); if (!stale) { batch.set(subRef, { platform: claim.platform, productId: claim.productId, transactionId: claim.transactionId, state: active ? 'active' : 'expired', adsRemoved: active, ...(typeof expiresAt === 'number' ? { expiresAt: Timestamp.fromMillis(expiresAt) } : {}), updatedAt: FieldValue.serverTimestamp(), }, { merge: true }); } batch.set(purchaseRef, { ...base, kind: 'subscription', itemName: title || 'Focus 구독', amountPop: 0, state: 'granted', ...(typeof expiresAt === 'number' ? { expiresAt: Timestamp.fromMillis(expiresAt) } : {}), }, { merge: true }); await batch.commit(); return { granted: 0, subscription: active ? 'active' : 'expired' }; } const gift = await heroGiftProduct(claim.productId); if (!gift && claim.productId.startsWith(POP_PACK_PREFIX) && (await loadPolicy()).popPackSalesEnabled !== true) { // B-02 — POP 자체 판매 상품은 두지 않는다. 스토어에 남아 있어도 지급하지 않는다. fail(ERR.payInvalidReceipt, '판매하지 않는 상품입니다.'); } const amount = gift ? gift.buyerPop : popForProduct(claim.productId); // Hero Gift 는 구매자의 소속 팬덤에 Drop Pool 을 연다. 팬덤은 앱이 아니라 사용자 문서에서 읽는다. let drop: { fandomId: string; dropId: string; doc: ReturnType } | null = null; if (gift) { const user = await db.doc(`users/${uid}`).get(); // 재시도(지급 전 실패)는 첫 시도에 적어 둔 팬덤을 쓴다 — 그 사이 행성을 옮겨도 Drop 이 가는 곳은 같다. const fandomId = existing.get('fandomId') ?? user.get('fandomId'); if (typeof fandomId !== 'string' || fandomId.length === 0) { // 재시도해도 바뀌지 않는 거부라 E-PAY-01 — 앱이 거래를 닫는다. fail(ERR.payInvalidReceipt, '소속 행성이 있어야 영웅의 선물을 보낼 수 있습니다.'); } drop = { fandomId, dropId: txIdOf(`herodrop:${iapKey(claim)}`), doc: dropDocument(await dropConfig(gift.dropPop), { triggeredBy: uid, heroNickname: publicNickname(user), productId: claim.productId, }), }; } await purchaseRef.set({ ...base, kind: gift ? 'hero_gift' : 'pop_benefit', itemName: gift?.name ?? (title || claim.productId), amountPop: amount, state: 'verified', ...(drop ? { fandomId: drop.fandomId, dropId: drop.dropId, dropPop: gift!.dropPop } : {}), }, { merge: true }); const result = await grantPop({ uid, popType: 'support', amount, reasonCode: 'IAP_PURCHASE', idempotencyKey: iapKey(claim), refPath: purchaseRef.path, // Drop 은 지급과 같은 트랜잭션에서 한 번만 만든다. 재시도는 원장 멱등으로 건너뛴다. ...(drop ? { alsoWrite: (tx) => tx.create( db.doc(`fandoms/${drop.fandomId}/heroDrops/${drop.dropId}`), drop.doc), } : {}), }); await purchaseRef.set({ state: 'granted', txId: result.txId, updatedAt: FieldValue.serverTimestamp(), }, { merge: true }); return { txId: result.txId, balanceAfter: result.balanceAfter, granted: amount, ...(drop ? { dropId: drop.dropId } : {}), }; }); /** * `fn-iap-refund-webhook` — 스토어 환불·취소 알림 처리. * * 별첨 1 ⑥ 예외 처리 「환불 결제 건 지급 회수」. * * **잔액이 모자라도 음수 잔액을 만들지 않는다.** 이미 쓴 POP 을 강제로 빼면 * 원장 합과 잔액이 어긋나고, 사용자는 쓴 적 없는 마이너스를 보게 된다. * 대신 회수 대기로 남기고 Admin 큐로 넘긴다. */ export const fnIapRefundWebhook = onCall(async (req) => { // 스토어 서명 검증 어댑터가 연결되기 전에는 신뢰된 관리자만 호출할 수 있다. requireRole(req, ['master']); const uid = str(req.data, 'uid'); const platform = platformOf(req.data); const transactionId = str(req.data, 'transactionId'); const db = getFirestore(); const purchaseId = `${platform}_${transactionId}`; const purchaseRef = db.doc(`users/${uid}/purchases/${purchaseId}`); const purchase = await purchaseRef.get(); if (!purchase.exists) { fail(ERR.payVerifyFailed, '해당 결제를 찾을 수 없습니다.'); } if (purchase.get('state') === 'clawed-back') { // 스토어 알림은 중복 도착이 흔하다. 두 번 회수하지 않는다. return { state: 'clawed-back', recovered: 0 }; } const amount = (purchase.get('amountPop') as number | undefined) ?? 0; const wallet = await db.doc(`users/${uid}/wallet/main`).get(); const balance = (wallet.get('supportBalance') as number | undefined) ?? 0; if (balance < amount) { await purchaseRef.set({ state: 'clawback-pending', shortfall: amount - balance, updatedAt: FieldValue.serverTimestamp(), }, { merge: true }); await db.collection('adminActionLogs').add({ actorUid: 'system', targetPath: purchaseRef.path, reason: 'IAP 환불 회수 보류 — 잔액 부족', patch: { amount, balance }, createdAt: FieldValue.serverTimestamp(), }); logger.warn('[iap] 회수 보류', { uid, purchaseId, amount, balance }); return { state: 'clawback-pending', recovered: 0, shortfall: amount - balance }; } const result = await spendPop({ uid, popType: 'support', amount, reasonCode: 'ADMIN_CLAWBACK', idempotencyKey: `clawback:${platform}:${transactionId}`, refPath: purchaseRef.path, }); await purchaseRef.set({ state: 'clawed-back', clawbackTxId: result.txId, updatedAt: FieldValue.serverTimestamp(), }, { merge: true }); return { state: 'clawed-back', recovered: amount, txId: result.txId }; }); /** 이벤트 → 상태. `adsRemoved` 가 혜택이 살아 있는지를 나타낸다. */ const STATE_BY_EVENT: Readonly< Record > = { purchased: { state: 'active', adsRemoved: true }, renewed: { state: 'active', adsRemoved: true }, // 해지해도 만료 전까지는 혜택이 남는다. canceled: { state: 'canceled', adsRemoved: true }, expired: { state: 'expired', adsRemoved: false }, revoked: { state: 'revoked', adsRemoved: false }, }; /** * `fn-subscription-webhook` — 구독 상태 전이. * * 결제 · 갱신 · 해지 · 만료 넷을 받는다. **해지와 만료를 가른다** — 해지는 * 갱신을 끄는 것이고 남은 기간 동안 혜택이 살아 있다. 둘을 합치면 사용자가 * 돈을 낸 기간에 광고가 다시 붙는다. */ export const fnSubscriptionWebhook = onCall(async (req) => { requireRole(req, ['master']); const uid = str(req.data, 'uid'); const platform = platformOf(req.data); const productId = str(req.data, 'productId'); const event = str(req.data, 'event'); const notificationId = str(req.data, 'notificationId'); if (!SUBSCRIPTION_PRODUCTS.includes(productId)) { fail(ERR.paySubscription, '구독 상품이 아닙니다.'); } const next = STATE_BY_EVENT[event]; if (!next) fail(ERR.paySubscription, '알 수 없는 구독 이벤트입니다.'); const db = getFirestore(); const seen = db.doc(`users/${uid}/subscriptionEvents/${notificationId}`); if ((await seen.get()).exists) { // 스토어 알림 ID 로 멱등을 잡는다. 재전송이 상태를 되돌리면 안 된다. return { state: next.state, duplicate: true }; } const expiresAtMillis = (req.data as Record)?.expiresAtMillis; await db.doc(`users/${uid}/subscription/current`).set({ platform, productId, state: next.state, adsRemoved: next.adsRemoved, ...(typeof expiresAtMillis === 'number' ? { expiresAt: Timestamp.fromMillis(expiresAtMillis) } : {}), updatedAt: FieldValue.serverTimestamp(), }, { merge: true }); await seen.set({ event, productId, createdAt: FieldValue.serverTimestamp(), }); return { state: next.state, duplicate: false }; }); /** * `fn-focus-daily-claim` — Focus 구독 혜택, 정책 원장 **B-04**. * * *광고 제거 + 일 40 기여 POP, 당일 Claim, 미수령 누적 없음.* 하루는 서버 일 단위(KST * `dayKey`, 퀘스트 한도와 같은 경계)다. 멱등키가 `focus:{uid}:{day}` 라 같은 날 두 번 지급되지 * 않고, 지난 날 몫은 키가 달라도 **오늘 부르는 것만** 지급하므로 이월이 없다. * * 성장 반영은 하지 않는다 — C-31 「Focus 일 40 기여 POP 등 다른 보너스에는 자동 적용하지 * 않음(적용 여부는 §19 S01)」. S01 회신 전까지 지급 시 성장 이벤트를 만들지 않는다. */ export const fnFocusDailyClaim = onCall(async (req) => { const uid = callerUid(req); const db = getFirestore(); const subRef = db.doc(`users/${uid}/subscription/current`); const sub = await subRef.get(); const expiresAt = sub.get('expiresAt'); if (!(expiresAt instanceof Timestamp) || expiresAt.toMillis() <= Date.now() || !['active', 'canceled'].includes(sub.get('state') as string)) { // 해지(canceled)는 만료 전까지 혜택이 남는다(fnSubscriptionWebhook 과 같은 규칙). fail(ERR.paySubscription, 'Focus 구독 중일 때만 받을 수 있습니다.'); } const day = dayKey(new Date()); const idempotencyKey = `focus:${uid}:${day}`; const prior = await db.doc(`users/${uid}/popTransactions/${txIdOf(idempotencyKey)}`).get(); const amount = (await loadPolicy()).focusDailyPop; const result = await grantPop({ uid, popType: 'support', amount, reasonCode: 'FOCUS_DAILY', idempotencyKey, refPath: subRef.path, // 앱이 「오늘 받음」을 그리는 값. 원장과 같은 트랜잭션에서 쓴다. alsoWrite: (tx) => tx.set(subRef, { focusClaimedDay: day }, { merge: true }), }); return { txId: result.txId, balanceAfter: result.balanceAfter, granted: prior.exists ? ((prior.get('amount') as number | undefined) ?? amount) : amount, day, duplicate: prior.exists, }; });