453 lines
19 KiB
TypeScript
453 lines
19 KiB
TypeScript
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,
|
|
};
|
|
});
|