Cloud Functions 도메인 함수 — POP 원장 · 퀘스트 · 이클립스 · 행성 성장 · 캠페인 · 결제 · 알림 · 브리핑 · INSIDE · 추천 · 번역 · 서버 테스트

This commit is contained in:
Sn-Kinos
2026-09-29 17:06:48 +09:00
parent f483e8d94b
commit 316821e7d5
61 changed files with 14660 additions and 60 deletions
+452
View File
@@ -0,0 +1,452 @@
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<Record<Platform, string>> = {
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<HeroGiftProduct | null> {
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<void> {
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<string, unknown>;
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<typeof dropDocument> } | 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<string, { state: string; adsRemoved: boolean }>
> = {
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<string, unknown>)?.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,
};
});
@@ -0,0 +1,86 @@
import { ERR, fail } from '../core/errors.js';
/**
* 영수증 검증 경계.
*
* **스토어 서버 호출만 여기 뒤에 둔다.** 검증에는 Apple 의 App Store Server API
* 키와 Google 의 서비스 계정이 필요한데, 둘 다 스토어 계정이 열려야 발급된다
* (계정·권한 이전 별첨 B-1·C-1). 그 자격증명을 기다리는 동안 지급·회수 로직이
* 통째로 멈추지 않도록 **검증만 인터페이스로 빼고 나머지는 지금 완성한다.**
*
* 자격증명이 들어오면 [StoreReceiptVerifier] 안쪽만 채우면 되고, 이 파일을
* 쓰는 함수들은 손대지 않는다.
*/
export type Platform = 'ios' | 'android';
export interface ReceiptClaim {
platform: Platform;
/** 스토어가 이 구매에 부여한 고유 ID. 멱등키의 뼈대다. */
transactionId: string;
productId: string;
/** 소비성 구매인가. 구독은 false. */
consumable: boolean;
purchasedAtMillis: number;
/** 스토어가 이미 환불·취소 처리한 건인가. */
revoked: boolean;
/** 구독 만료 시각. 구독이 아니거나 스토어가 주지 않았으면 없다. */
expiresAtMillis?: number;
}
/**
* 조회 키. **지급 기준으로 쓰지 않는다** — 지급은 스토어가 돌려준 [ReceiptClaim] 으로 정한다.
* - `productId`: Google Play 구매 조회 API 가 토큰과 함께 요구한다.
* - `transactionId`: iOS 앱 영수증은 여러 거래를 담는다. 앱이 방금 받은 거래를 이 ID 로 고른다.
*/
export interface ReceiptHint {
productId?: string | undefined;
transactionId?: string | undefined;
}
export interface ReceiptVerifier {
verify(platform: Platform, receipt: string, hint?: ReceiptHint): Promise<ReceiptClaim>;
}
/**
* 상품 표 — 상품 ID 하나가 지원 POP 얼마인지.
*
* **클라이언트가 보낸 금액을 믿지 않는다.** 앱이 「10만 POP 짜리를 샀다」고 말해도
* 서버는 상품 ID 로 이 표를 본다. 표에 없는 상품 ID 는 지급하지 않는다.
*/
export const PRODUCT_POP: Readonly<Record<string, number>> = {
'pop.support.1000': 1_000,
'pop.support.5500': 5_500,
'pop.support.12000': 12_000,
'pop.support.30000': 30_000,
'pop.support.65000': 65_000,
};
/** 구독 상품. POP 을 주지 않고 광고 제거 상태를 바꾼다. */
export const SUBSCRIPTION_PRODUCTS: readonly string[] = [
'focus.monthly',
'focus.yearly',
];
export function popForProduct(productId: string): number {
const amount = PRODUCT_POP[productId];
if (amount === undefined) {
// 표에 없는 상품 ID — 위조이거나 스토어 상품 등록과 코드가 어긋난 상태다.
// 둘 다 지급하면 안 된다.
fail(ERR.payInvalidReceipt, '알 수 없는 상품입니다.');
}
return amount;
}
/**
* 스토어 실호출 구현 — **자격증명 대기 중이라 아직 비어 있다.**
*
* 비워 두되 조용히 통과시키지 않는다. 자격증명 없이 부르면 `E-PAY-02` 로 끊어
* **검증 없는 지급 경로가 생기지 않게** 한다. 「나중에 채우자」는 주석만 두면
* 그 자리가 열린 채로 배포된다.
*/
export class StoreReceiptVerifier implements ReceiptVerifier {
async verify(_platform: Platform, _receipt: string, _hint?: ReceiptHint): Promise<ReceiptClaim> {
return fail(ERR.payVerifyFailed, '결제 검증 설정이 아직 준비되지 않았습니다.');
}
}