Cloud Functions 도메인 함수 — POP 원장 · 퀘스트 · 이클립스 · 행성 성장 · 캠페인 · 결제 · 알림 · 브리핑 · INSIDE · 추천 · 번역 · 서버 테스트
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
import { getFirestore, FieldValue } from 'firebase-admin/firestore';
|
||||
import type { DocumentSnapshot, Transaction } from 'firebase-admin/firestore';
|
||||
|
||||
import type { FpiAxis } from '../core/config.js';
|
||||
import { levelOf, passportTierOf, type PassportTier } from '../core/level.js';
|
||||
|
||||
/**
|
||||
* FPI 원천 이벤트 — 정책 원장 **C-34 · C-35 · G-05**, 설계서 §4.1.1.
|
||||
*
|
||||
* 축 값은 **인정된 활동 단위**다. 지급 POP 은 원천이 아니므로 이 문서에 POP 필드를
|
||||
* 두지 않는다 — 보상 750 POP 인 Quest 와 1 POP 인 Quest 의 같은 활동은 같은 점수다.
|
||||
*
|
||||
* 축 매핑(C-35): 출석 · 성지/생일 팝업/응원 현장 방문 = community · 걸음/운동 = health ·
|
||||
* Music Shake = bpm · 추천 = referral · Zap/후원 = contribution.
|
||||
*/
|
||||
|
||||
/** 활동 1 단위의 축 값. S01 확정 전 유지 기준(설계서 §4.1.1 「축별 계수 초기값 1.0」). */
|
||||
export const FPI_UNIT_FACTOR = 1.0;
|
||||
|
||||
/**
|
||||
* 귀속 팬덤은 서버가 정한다 — `primaryFandomId ?? fandomId`.
|
||||
*
|
||||
* 클라이언트가 보낸 `fandomId` 는 쓰지 않는다. 남의 행성 축을 올리는 길이 된다.
|
||||
* 성장 귀속(`fandomIdAtPayout`)도 같은 판정을 쓴다(C-33).
|
||||
*/
|
||||
export function fandomOfUser(user: DocumentSnapshot | null | undefined): string | null {
|
||||
const id = user?.get('primaryFandomId') || user?.get('fandomId');
|
||||
return typeof id === 'string' && id.length > 0 && !id.includes('/') ? id : null;
|
||||
}
|
||||
|
||||
export interface FpiActivity {
|
||||
uid: string;
|
||||
fandomId: string | null;
|
||||
axis: FpiAxis;
|
||||
/** 인정 활동 단위 수(걸음은 새로 도달한 구간 수, Zap 은 누른 횟수). */
|
||||
units: number;
|
||||
/** 활동 종류 — 무효 정정 때 원 활동을 찾는 표식. */
|
||||
activity: string;
|
||||
/** 원 활동 ID. 문서 ID 가 `{uid}_{sourceId}` 라 같은 활동은 한 번만 남는다. */
|
||||
sourceId: string;
|
||||
occurredAt: Date;
|
||||
refPath?: string;
|
||||
/**
|
||||
* User EXP 를 주는가. 기본 true. 반복 비용이 낮아 파밍되는 활동(Zap · 후원 — 1 POP 단위로
|
||||
* 쪼갤 수 있다)과 본인 행위가 아닌 활동(추천인 귀속)은 false 다.
|
||||
*/
|
||||
grantsExp?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 인정 활동 1건의 User EXP — 정책 원장 **C-18**.
|
||||
*
|
||||
* S01 확정 전 임시값. 누적 기여 POP 을 레벨로 치환하지 않도록(C-18) 금액이 아니라 **서버가
|
||||
* 인정한 활동 건수**로 쌓는다 — 보상 750 POP 출석과 1 POP 출석이 같은 1건이다. 대상은 서버가
|
||||
* 한도 · 1회성을 판정하는 Quest 활동(출석 · 걸음 구간 · Music Shake · 현장 방문)뿐이다.
|
||||
* Zap · 후원 · 추천인 귀속은 `grantsExp: false`(파밍 · 본인 행위 아님).
|
||||
*/
|
||||
export const EXP_PER_ACTIVITY = 100;
|
||||
|
||||
/**
|
||||
* 원 활동과 **같은 트랜잭션에서** FPI 이벤트와 User EXP 를 남긴다.
|
||||
*
|
||||
* 따로 쓰면 지급은 됐는데 FPI 는 빠진 상태가 남고, 재시도는 일일 한도에 막혀 영영
|
||||
* 채워지지 않는다. `create` 라 같은 활동을 두 번 적으면 트랜잭션째 실패한다.
|
||||
*
|
||||
* `user` 는 **이 트랜잭션에서 이미 읽은** 활동 주체의 사용자 문서다(읽기가 쓰기보다 앞서야
|
||||
* 한다). 레벨 · Passport Tier 는 EXP 의 파생값이라 함께 갱신하고 Passport 는 내려가지 않는다.
|
||||
* 팬덤이 없어도 EXP 는 쌓인다 — 레벨은 개인 체계다(B-66).
|
||||
*/
|
||||
export function writeFpiEvent(
|
||||
tx: Transaction, event: FpiActivity, user: DocumentSnapshot | null,
|
||||
): void {
|
||||
if (event.units <= 0) return;
|
||||
if (event.grantsExp !== false) writeUserExp(tx, event.uid, user);
|
||||
if (!event.fandomId) return;
|
||||
tx.create(
|
||||
getFirestore().doc(`fandoms/${event.fandomId}/fpiEvents/${event.uid}_${event.sourceId}`),
|
||||
{
|
||||
axis: event.axis,
|
||||
value: event.units * FPI_UNIT_FACTOR,
|
||||
units: event.units,
|
||||
activity: event.activity,
|
||||
uid: event.uid,
|
||||
verified: true,
|
||||
occurredAt: event.occurredAt,
|
||||
sourceId: event.sourceId,
|
||||
// 일별 DAU(Eclipse 목표 입력)는 본인이 직접 한 인정 활동만 센다. 추천인 귀속은 제외.
|
||||
dauEligible: event.activity !== 'referral',
|
||||
...(event.refPath ? { refPath: event.refPath } : {}),
|
||||
createdAt: FieldValue.serverTimestamp(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 인정 활동 1건의 User EXP(C-18) — 레벨 · Passport Tier 함께. FPI 축 없이 EXP 만 줄 때
|
||||
* (타임어택 FPI 축이 꺼졌을 때) 직접 부른다.
|
||||
*/
|
||||
export function writeUserExp(tx: Transaction, uid: string, user: DocumentSnapshot | null): void {
|
||||
const exp = ((user?.get('userExp') as number | undefined) ?? 0) + EXP_PER_ACTIVITY;
|
||||
const level = levelOf(exp);
|
||||
tx.set(getFirestore().doc(`users/${uid}`), {
|
||||
userExp: exp,
|
||||
userLevel: level,
|
||||
passportTier: passportTierOf(level, user?.get('passportTier') as PassportTier | undefined),
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
}, { merge: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* 「검증 활동」 1회 — 해석 확인 요청 Campaign 개설자격 **A안** · 집계 규칙(2026-09-28 결정).
|
||||
*
|
||||
* 서버가 인정한 Quest 완료를 **완료 단위 표식** `users/{uid}/verifiedCompletions/{id}` 하나로 남긴다.
|
||||
* `id` 가 곧 「하루 1회」 경계다 — 출석 · 걸음(구간 무관) · 방문 · UGC · 타임어택은
|
||||
* `{questId}_{dayKey}`, Music Shake 는 완주마다 `{questId}_{dayKey}_{n}`. 같은 날 같은 표식을
|
||||
* 다시 써도(구간 · 재시도) `merge` 라 하나로 모인다. 보상 유무 · 팬덤 유무와 무관하다.
|
||||
*
|
||||
* 무효 정정은 원장 행(`txIds`) 또는 제출 건(`submissionId`)으로 찾는다([voidCompletions]).
|
||||
*/
|
||||
export function markCompletion(
|
||||
tx: Transaction, uid: string, id: string,
|
||||
ref: { questId: string; txId?: string; submissionId?: string },
|
||||
): void {
|
||||
tx.set(getFirestore().doc(`users/${uid}/verifiedCompletions/${id}`), {
|
||||
questId: ref.questId,
|
||||
...(ref.txId ? { txIds: FieldValue.arrayUnion(ref.txId) } : {}),
|
||||
...(ref.submissionId ? { submissionId: ref.submissionId } : {}),
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
}, { merge: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* 「검증 활동」 누적 수 = 표식 수 − 무효 표식 수. 기간 창 없음. Quest 생성자격 1회 · Campaign
|
||||
* 생성자격 5회가 같은 값을 읽는다. 저장 카운터가 아니라 표식에서 세므로 음수 · 이중 차감이 없다.
|
||||
*/
|
||||
export async function verifiedQuestCountOf(uid: string): Promise<number> {
|
||||
const col = getFirestore().collection(`users/${uid}/verifiedCompletions`);
|
||||
const [all, voided] = await Promise.all([
|
||||
col.count().get(), col.where('voided', '==', true).count().get(),
|
||||
]);
|
||||
return Math.max(0, all.data().count - voided.data().count);
|
||||
}
|
||||
|
||||
/** 원장 행 또는 제출 건에 걸린 표식을 무효로 — 트랜잭션 안에서 **읽기 단계**에 부른다. 쓰기는 반환 함수로. */
|
||||
export async function voidCompletions(
|
||||
tx: Transaction, uid: string, key: { txId?: string; submissionId?: string },
|
||||
): Promise<(tx: Transaction) => number> {
|
||||
const col = getFirestore().collection(`users/${uid}/verifiedCompletions`);
|
||||
const snap = key.txId
|
||||
? await tx.get(col.where('txIds', 'array-contains', key.txId))
|
||||
: key.submissionId ? await tx.get(col.where('submissionId', '==', key.submissionId)) : null;
|
||||
const live = (snap?.docs ?? []).filter((d) => d.get('voided') !== true);
|
||||
return (w) => {
|
||||
for (const d of live) w.update(d.ref, { voided: true, voidedAt: FieldValue.serverTimestamp() });
|
||||
return live.length;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 공동 순위 — 정책 원장 **C-19**.
|
||||
*
|
||||
* 실제 산정값(반올림 전) 내림차순, 동점은 같은 순위이고 다음 순위는 건너뛴다(1·1·3).
|
||||
* 동점 안의 배치는 `key` 오름차순으로 고정한다(S01 확정 전 유지 기준 — 2차 정렬키
|
||||
* `fandomId`). 최근 활동 시각은 쓰지 않는다.
|
||||
*/
|
||||
export function rankWithTies<T>(
|
||||
items: readonly T[], value: (item: T) => number, key: (item: T) => string,
|
||||
): Array<{ item: T; rank: number }> {
|
||||
const sorted = [...items].sort((a, b) =>
|
||||
value(b) - value(a) || (key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0));
|
||||
let rank = 0;
|
||||
return sorted.map((item, i) => {
|
||||
if (i === 0 || value(item) !== value(sorted[i - 1]!)) rank = i + 1;
|
||||
return { item, rank };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,831 @@
|
||||
import { getFirestore, FieldValue, Timestamp } from 'firebase-admin/firestore';
|
||||
import type { DocumentReference, Firestore, Transaction } from 'firebase-admin/firestore';
|
||||
|
||||
import { ERR, fail } from '../core/errors.js';
|
||||
import { lotIdOf, txIdOf } from '../core/ids.js';
|
||||
import { levelOf, passportRank, passportTierOf, type PassportTier } from '../core/level.js';
|
||||
import { fandomOfUser, voidCompletions, writeFpiEvent } from './fpi.js';
|
||||
import { publicNameOf } from '../core/names.js';
|
||||
|
||||
/**
|
||||
* POP 원장 — 별첨 1 「조건부 적합 제외 항목 ② POP/FPI 데이터 정합성」의 구현부.
|
||||
*
|
||||
* 이 파일이 계약 검수의 핵심이다. 정합성 오류가 나면 조건부 적합이 아니라
|
||||
* **부적합(재검수)** 이므로, 원장을 건드리는 모든 경로가 여기를 지나게 했다.
|
||||
* 화면이나 다른 함수가 지갑 문서를 직접 쓰는 일은 없어야 한다.
|
||||
*
|
||||
* 지키는 것 셋
|
||||
* 1. **append-only** — 과거 원장 행을 고치지 않는다. 정정은 반대 부호의 새 행이다.
|
||||
* 2. **멱등** — `txId = SHA-256(idempotencyKey)[0..20]` 이고 항상 `create()` 한다.
|
||||
* 재시도가 겹쳐도 두 번째가 `ALREADY_EXISTS` 로 떨어진다.
|
||||
* 3. **혼합 차감 금지** — 원장 한 행은 `popType` 하나다. 활동(일반) POP 과 지원(기여)
|
||||
* POP 은 회계가 다르므로 한 행이 둘을 함께 건드릴 수 없다. 겸용 사용처(B-06)는
|
||||
* `spendSplit` 이 타입별 행 2건을 한 트랜잭션에 쓴다.
|
||||
*/
|
||||
|
||||
export type PopType = 'activity' | 'support';
|
||||
export type TxType = 'grant' | 'spend' | 'refund' | 'void';
|
||||
|
||||
export type ReasonCode =
|
||||
| 'QUEST_STEPS' | 'QUEST_MUSIC_SHAKE' | 'QUEST_PLACE' | 'QUEST_ATTEND' | 'QUEST_TIMEATTACK'
|
||||
| 'AD_REWARD' | 'HERO_DROP' | 'TRANSLATION' | 'IAP_PURCHASE' | 'FOCUS_DAILY'
|
||||
| 'ADMIN_GRANT' | 'PREREG_BONUS' | 'QNA_REWARD' | 'REFERRAL_REWARD'
|
||||
| 'CAMPAIGN_FUND' | 'QNA_BOUNTY' | 'ECLIPSE_DEFENSE' | 'HERO_DROP_TRIGGER'
|
||||
| 'ZAP_CONTRIBUTION' | 'CAMPAIGN_FEE'
|
||||
| 'CAMPAIGN_REFUND' | 'QNA_REFUND' | 'ADMIN_CLAWBACK'
|
||||
// 등록비 반환(B-58 반려 · 회사귀책 · 공개 전 철회). MY 표기 「캠페인 개설 수수료 환불」.
|
||||
| 'CAMPAIGN_FEE_REFUND'
|
||||
// 사용자 생성 Quest(C-02 · C-09 · B-30) — 참여 보상 · 수수료(Burn) · Pool 예치 · 미사용분 반환 ·
|
||||
// 공개 전 실패 시 예치 원타입 환불.
|
||||
| 'QUEST_UGC' | 'QUEST_FEE' | 'QUEST_POOL_DEPOSIT' | 'QUEST_POOL_RETURN' | 'QUEST_POOL_REFUND';
|
||||
|
||||
/**
|
||||
* 기여로 집계하는 차감 사유 — `user.totalContributedPop` 의 모집단(§3.6).
|
||||
*
|
||||
* `ZAP_CONTRIBUTION` 은 **행성 성장 기여**다(별첨 B **B-23** — *ZAP 차감액은 누적 기여
|
||||
* POP 에 실제 값으로 반영되고, 행성 성장용 Growth 진행도에만 가중을 둔다*). 가중은
|
||||
* 원장과 sFPI 에 적용하지 않으므로 여기서는 실제 차감액 그대로 쌓인다.
|
||||
*
|
||||
* `ECLIPSE_DEFENSE` 는 남겨 두지만 **더 이상 쓰이지 않는다** — 이클립스 방어는 POP 을
|
||||
* 소모하지 않는 공동 Shake 참여로 확정되었다(설계서 v1.1 §9-#5). 과거 원장 행을
|
||||
* 읽는 쪽이 있어 사유 코드 자체는 지우지 않는다(카탈로그는 append-only).
|
||||
*/
|
||||
export const CONTRIBUTION_REASONS: readonly ReasonCode[] = [
|
||||
'CAMPAIGN_FUND', 'QNA_BOUNTY', 'ECLIPSE_DEFENSE', 'ZAP_CONTRIBUTION',
|
||||
];
|
||||
|
||||
export interface LotRef { lotId: string; delta: number }
|
||||
|
||||
export interface GrantInput {
|
||||
uid: string;
|
||||
popType: PopType;
|
||||
amount: number;
|
||||
reasonCode: ReasonCode;
|
||||
idempotencyKey: string;
|
||||
refPath?: string;
|
||||
actorUid?: string;
|
||||
/** 검수 대기 지급(정책설계서 P-02). 잔액이 아니라 `pendingActivity` 에 쌓인다. */
|
||||
pending?: boolean;
|
||||
/** 검증된 보상 경로가 지정한다. 일반 광고/관리자 지급은 자동 포함하지 않는다. */
|
||||
growthSource?: 'quest_reward' | 'company_ad_bonus';
|
||||
/**
|
||||
* 지급과 **같은 트랜잭션에서** 함께 쓸 것.
|
||||
*
|
||||
* 퀘스트 진행 카운터가 대표적이다. 지급 뒤에 따로 쓰면 그 사이 실패가 났을 때
|
||||
* 「POP 은 나갔는데 일일 한도는 0」인 상태가 남고, 그 상태에서 한 번 더 부르면
|
||||
* 같은 날 두 번 지급된다. 같은 트랜잭션이면 둘이 함께 서거나 함께 없다.
|
||||
*
|
||||
* 멱등키가 같은 재시도에서는 부르지 않는다 — 첫 호출이 이미 썼다.
|
||||
* `user` 는 이 트랜잭션에서 읽은 사용자 문서다(`growthSource` 지급일 때). FPI 이벤트와
|
||||
* EXP(`writeFpiEvent`)가 이 값으로 레벨을 다시 매긴다.
|
||||
*/
|
||||
alsoWrite?: (tx: Transaction, user: FirebaseFirestore.DocumentSnapshot | null) => void;
|
||||
}
|
||||
|
||||
export interface SpendInput {
|
||||
/**
|
||||
* 차감 전에 **같은 트랜잭션에서** 읽고 판정할 것(예: 캠페인 목표 잔여분). 던지면 차감도
|
||||
* 없다. 모든 읽기가 쓰기보다 앞서야 하므로 원장 쓰기 전에 부른다. 재시도에서는 부르지 않는다.
|
||||
*/
|
||||
precheck?: (tx: Transaction) => Promise<void>;
|
||||
/** 차감과 함께 확정할 이벤트. 재시도에서는 다시 쓰지 않는다. `parts` 는 이번에 쓴 원장 행이다. */
|
||||
alsoWrite?: (tx: Transaction, parts: SpendPart[]) => void;
|
||||
uid: string;
|
||||
popType: PopType;
|
||||
amount: number;
|
||||
reasonCode: ReasonCode;
|
||||
idempotencyKey: string;
|
||||
refPath?: string;
|
||||
fandomId?: string;
|
||||
/**
|
||||
* 한 행이 몇 번의 행위를 묶은 것인가. 기본값은 두지 않는다 — 없으면 적지 않는다.
|
||||
*
|
||||
* ZAP 연타처럼 화면이 여러 번의 누름을 한 호출로 묶으면 `amount` 만으로는 횟수를
|
||||
* 복원할 수 없다. MY 의 POP 내역이 「Zap N 회」를 **행 수로 세면 실제보다 작게**
|
||||
* 나오므로, 묶은 횟수를 원장 행에 그대로 남긴다.
|
||||
*/
|
||||
count?: number;
|
||||
}
|
||||
|
||||
export interface LedgerResult {
|
||||
txId: string;
|
||||
balanceAfter: number;
|
||||
lotRefs: LotRef[];
|
||||
}
|
||||
|
||||
const balanceField = (t: PopType) =>
|
||||
t === 'activity' ? 'activityBalance' : 'supportBalance';
|
||||
|
||||
const walletRef = (db: Firestore, uid: string) =>
|
||||
db.doc(`users/${uid}/wallet/main`);
|
||||
|
||||
/**
|
||||
* 지갑을 읽는다. 없으면 빈 지갑으로 본다.
|
||||
*
|
||||
* 트리거가 만들기 전에 첫 지급이 도착할 수 있으므로(경합) 여기서 없다고 실패시키지
|
||||
* 않는다. 같은 트랜잭션 안에서 `set(merge)` 로 만들면 된다.
|
||||
*/
|
||||
async function readWallet(tx: Transaction, db: Firestore, uid: string) {
|
||||
const ref = walletRef(db, uid);
|
||||
const snap = await tx.get(ref);
|
||||
const data = snap.data() ?? {};
|
||||
return {
|
||||
ref,
|
||||
exists: snap.exists,
|
||||
activityBalance: (data.activityBalance as number | undefined) ?? 0,
|
||||
supportBalance: (data.supportBalance as number | undefined) ?? 0,
|
||||
pendingActivity: (data.pendingActivity as number | undefined) ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** 원장과 같은 트랜잭션에서만 호출한다. 사용자별 원 거래 ID가 성장 멱등 키다. */
|
||||
function writeGrowth(
|
||||
tx: Transaction, db: Firestore, uid: string, txId: string,
|
||||
fandomId: string, amount: number,
|
||||
sourceType: 'quest_reward' | 'company_ad_bonus' | 'zap' | 'funding',
|
||||
): void {
|
||||
tx.create(db.doc(`users/${uid}/growthEvents/${txId}`), {
|
||||
sourceEventId: txId,
|
||||
sourceType,
|
||||
fandomIdAtPayout: fandomId,
|
||||
amount,
|
||||
reversalOf: null,
|
||||
reason: null,
|
||||
createdAt: FieldValue.serverTimestamp(),
|
||||
});
|
||||
tx.set(db.doc(`fandoms/${fandomId}`), {
|
||||
growthPop: FieldValue.increment(amount),
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
}, { merge: true });
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 지급 — `fn-pop-grant` 의 코어.
|
||||
*
|
||||
* lot 하나와 원장 한 행을 만들고 지갑 잔액을 올린다. 셋이 한 트랜잭션이라
|
||||
* 부분 반영이 남지 않는다.
|
||||
*/
|
||||
export async function grantPop(input: GrantInput, transaction?: Transaction): Promise<LedgerResult> {
|
||||
const db = getFirestore();
|
||||
const txId = txIdOf(input.idempotencyKey);
|
||||
const lotId = lotIdOf(txId);
|
||||
const pending = input.pending === true;
|
||||
|
||||
if (pending && input.popType !== 'activity') {
|
||||
// 지원 POP 은 결제 확정분이라 검수 대기 상태가 존재하지 않는다.
|
||||
fail(ERR.popVerifyFailed, '지원 POP 은 보류 지급 대상이 아닙니다.');
|
||||
}
|
||||
|
||||
const write = async (tx: Transaction): Promise<LedgerResult> => {
|
||||
const wallet = await readWallet(tx, db, input.uid);
|
||||
const txRef = db.doc(`users/${input.uid}/popTransactions/${txId}`);
|
||||
const existing = await tx.get(txRef);
|
||||
if (existing.exists) {
|
||||
// 같은 멱등키의 재시도. 원장을 두 번 쓰지 않고 기존 결과를 그대로 돌려준다.
|
||||
const prev = existing.data() ?? {};
|
||||
return {
|
||||
txId,
|
||||
balanceAfter: (prev.balanceAfter as number | undefined) ?? 0,
|
||||
lotRefs: (prev.lotRefs as LotRef[] | undefined) ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
// 지급 순간 귀속을 원장에 고정한다. 재시도는 위 existing 분기에서 끝난다.
|
||||
const growthUser = input.growthSource && !pending
|
||||
? await tx.get(db.doc(`users/${input.uid}`)) : null;
|
||||
const fandomIdAtPayout = fandomOfUser(growthUser);
|
||||
|
||||
const current = input.popType === 'activity'
|
||||
? wallet.activityBalance
|
||||
: wallet.supportBalance;
|
||||
const balanceAfter = pending ? current : current + input.amount;
|
||||
const lotRefs: LotRef[] = [{ lotId, delta: input.amount }];
|
||||
|
||||
tx.create(db.doc(`users/${input.uid}/popLots/${lotId}`), {
|
||||
popType: input.popType,
|
||||
grantedAmount: input.amount,
|
||||
remaining: input.amount,
|
||||
grantedAt: FieldValue.serverTimestamp(),
|
||||
status: pending ? 'pending' : 'active',
|
||||
sourceTxId: txId,
|
||||
reasonCode: input.reasonCode,
|
||||
createdAt: FieldValue.serverTimestamp(),
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
});
|
||||
|
||||
tx.create(txRef, {
|
||||
type: 'grant',
|
||||
...(input.growthSource ? { fandomIdAtPayout, growthSource: input.growthSource } : {}),
|
||||
popType: input.popType,
|
||||
amount: input.amount,
|
||||
reasonCode: input.reasonCode,
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
...(input.refPath ? { refPath: input.refPath } : {}),
|
||||
...(input.actorUid ? { actorUid: input.actorUid } : {}),
|
||||
lotRefs,
|
||||
balanceAfter,
|
||||
// 검증 없는 지급 경로가 없다는 것을 원장 자체에 남긴다 — 별첨1 ② 판정 근거.
|
||||
serverVerified: true,
|
||||
pending,
|
||||
createdAt: FieldValue.serverTimestamp(),
|
||||
});
|
||||
|
||||
if (input.growthSource && !pending && fandomIdAtPayout && input.amount > 0) {
|
||||
writeGrowth(tx, db, input.uid, txId, fandomIdAtPayout, input.amount, input.growthSource);
|
||||
}
|
||||
|
||||
// FPI 이벤트 · EXP 를 적는 콜백은 이 트랜잭션에서 읽은 사용자 문서가 필요하다.
|
||||
input.alsoWrite?.(tx, growthUser);
|
||||
|
||||
tx.set(wallet.ref, {
|
||||
[balanceField(input.popType)]: pending
|
||||
? current
|
||||
: FieldValue.increment(input.amount),
|
||||
pendingActivity: pending
|
||||
? FieldValue.increment(input.amount)
|
||||
: wallet.pendingActivity,
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
...(wallet.exists ? {} : {
|
||||
activityBalance: input.popType === 'activity' && !pending ? input.amount : 0,
|
||||
supportBalance: input.popType === 'support' ? input.amount : 0,
|
||||
pendingActivity: pending ? input.amount : 0,
|
||||
reconcileStatus: 'ok',
|
||||
createdAt: FieldValue.serverTimestamp(),
|
||||
}),
|
||||
}, { merge: true });
|
||||
|
||||
return { txId, balanceAfter, lotRefs };
|
||||
};
|
||||
return transaction ? write(transaction) : db.runTransaction(write);
|
||||
}
|
||||
|
||||
/** 차감 한 행의 결과. 겸용 사용처에서 두 재화가 함께 쓰이면 두 행이다. */
|
||||
export interface SpendPart extends LedgerResult {
|
||||
popType: PopType;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 보류 지급 정산 — `grantPop({ pending: true })` 로 잡아 둔 금액을 **확정**하거나 **해제**한다.
|
||||
*
|
||||
* 원장은 append-only 라 원 보류 행을 고치지 않고 정산 행을 하나 더 쓴다. 정산 행 ID 가
|
||||
* `{원 멱등키}:settle` 로 고정이라 확정과 해제가 둘 다 일어날 수 없고, 재시도는 기존 결과를 돌려준다.
|
||||
* - confirm: lot `pending → active`, `pendingActivity -= n`, `activityBalance += n`, 정산 행 `grant`(n).
|
||||
* - release: lot `pending → void`(잔량 0), `pendingActivity -= n`, 정산 행 `void`(0).
|
||||
* 어느 쪽이든 `총 보유 = 예약(pendingActivity) + 사용 가능` 등식이 유지된다(QA 시험 사례 2-29).
|
||||
*/
|
||||
export async function settlePendingGrant(params: {
|
||||
uid: string;
|
||||
idempotencyKey: string;
|
||||
outcome: 'confirm' | 'release';
|
||||
}): Promise<LedgerResult> {
|
||||
const db = getFirestore();
|
||||
const originalTxId = txIdOf(params.idempotencyKey);
|
||||
const settleTxId = txIdOf(`${params.idempotencyKey}:settle`);
|
||||
const lotId = lotIdOf(originalTxId);
|
||||
|
||||
return db.runTransaction(async (tx) => {
|
||||
const wallet = await readWallet(tx, db, params.uid);
|
||||
const originalRef = db.doc(`users/${params.uid}/popTransactions/${originalTxId}`);
|
||||
const settleRef = db.doc(`users/${params.uid}/popTransactions/${settleTxId}`);
|
||||
const lotRef = db.doc(`users/${params.uid}/popLots/${lotId}`);
|
||||
const [original, settled, lot] = await Promise.all([
|
||||
tx.get(originalRef), tx.get(settleRef), tx.get(lotRef),
|
||||
]);
|
||||
if (settled.exists) {
|
||||
const prev = settled.data() ?? {};
|
||||
return {
|
||||
txId: settleTxId,
|
||||
balanceAfter: (prev.balanceAfter as number | undefined) ?? 0,
|
||||
lotRefs: (prev.lotRefs as LotRef[] | undefined) ?? [],
|
||||
};
|
||||
}
|
||||
if (!original.exists || original.get('pending') !== true || !lot.exists) {
|
||||
fail(ERR.popVerifyFailed, '정산할 보류 지급이 없습니다.');
|
||||
}
|
||||
const amount = (original.get('amount') as number | undefined) ?? 0;
|
||||
const confirm = params.outcome === 'confirm';
|
||||
const balanceAfter = wallet.activityBalance + (confirm ? amount : 0);
|
||||
const lotRefs: LotRef[] = [{ lotId, delta: confirm ? amount : 0 }];
|
||||
|
||||
tx.update(lotRef, confirm
|
||||
? { status: 'active', updatedAt: FieldValue.serverTimestamp() }
|
||||
: { status: 'void', remaining: 0, updatedAt: FieldValue.serverTimestamp() });
|
||||
tx.create(settleRef, {
|
||||
type: confirm ? 'grant' : 'void',
|
||||
popType: 'activity',
|
||||
amount: confirm ? amount : 0,
|
||||
reasonCode: original.get('reasonCode'),
|
||||
idempotencyKey: `${params.idempotencyKey}:settle`,
|
||||
settlesTxId: originalTxId,
|
||||
...(original.get('refPath') ? { refPath: original.get('refPath') } : {}),
|
||||
lotRefs,
|
||||
balanceAfter,
|
||||
serverVerified: true,
|
||||
pending: false,
|
||||
createdAt: FieldValue.serverTimestamp(),
|
||||
});
|
||||
tx.set(wallet.ref, {
|
||||
activityBalance: confirm ? FieldValue.increment(amount) : wallet.activityBalance,
|
||||
pendingActivity: FieldValue.increment(-amount),
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
}, { merge: true });
|
||||
return { txId: settleTxId, balanceAfter, lotRefs };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 차감 — `fn-pop-spend` 의 코어. 한 재화만 건드린다.
|
||||
*
|
||||
* 잔액이 모자라면 E-POP-01 로 끊는다. 같은 타입 안에서는 `grantedAt` 오름차순
|
||||
* (선입선출)으로 lot 을 소진한다(설계서 §3.2 ②).
|
||||
*/
|
||||
export async function spendPop(input: SpendInput): Promise<LedgerResult> {
|
||||
const [part] = await spendParts(input, [input.popType]);
|
||||
return { txId: part!.txId, balanceAfter: part!.balanceAfter, lotRefs: part!.lotRefs };
|
||||
}
|
||||
|
||||
/**
|
||||
* 겸용 사용처 차감 — 정책 원장 **B-06** · 설계서 §3.2.
|
||||
*
|
||||
* 일반(activity) POP 을 먼저 소진하고 부족분만 기여(support) POP 에서 뺀다. 두 재화가
|
||||
* 함께 쓰이면 원장을 `popType` 별로 **2건** 쓰되 한 트랜잭션이라 함께 서거나 함께 없다
|
||||
* (혼합 차감 금지 불변식은 행 단위로 그대로다). 합계가 모자라면 E-POP-01 이고 행은 0건이다.
|
||||
*
|
||||
* 멱등키 하나가 두 행을 묶는다 — 일반 행은 `txIdOf(key)`, 기여 행은 `txIdOf(key:support)`.
|
||||
* 재시도는 이미 있는 행을 그대로 돌려준다. 환불은 [refundSplit] 이 행마다 원래 타입으로 돌린다.
|
||||
*/
|
||||
export async function spendSplit(input: Omit<SpendInput, 'popType'>): Promise<SpendPart[]> {
|
||||
return spendParts(input, ['activity', 'support']);
|
||||
}
|
||||
|
||||
async function spendParts(
|
||||
input: Omit<SpendInput, 'popType'>, types: PopType[],
|
||||
): Promise<SpendPart[]> {
|
||||
if (!Number.isSafeInteger(input.amount) || input.amount <= 0) {
|
||||
fail(ERR.popVerifyFailed, '차감액은 양의 정수여야 합니다.');
|
||||
}
|
||||
const db = getFirestore();
|
||||
const txIds = types.map((t, i) =>
|
||||
txIdOf(i === 0 ? input.idempotencyKey : `${input.idempotencyKey}:${t}`));
|
||||
|
||||
// lot 부족이 드러난 시도. 트랜잭션 안의 쓰기는 실패와 함께 되돌려지므로 대사 표시는
|
||||
// 트랜잭션이 끝난 뒤 따로 쓴다.
|
||||
let mismatch = false;
|
||||
try {
|
||||
return await db.runTransaction((tx) => {
|
||||
mismatch = false;
|
||||
return spendInTx(tx);
|
||||
});
|
||||
} catch (error) {
|
||||
if (mismatch) {
|
||||
await walletRef(db, input.uid).set({
|
||||
reconcileStatus: 'mismatch',
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
}, { merge: true });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
async function spendInTx(tx: Transaction): Promise<SpendPart[]> {
|
||||
const txRefs = txIds.map((id) => db.doc(`users/${input.uid}/popTransactions/${id}`));
|
||||
const prior = (await Promise.all(txRefs.map((ref) => tx.get(ref)))).filter((s) => s.exists);
|
||||
if (prior.length > 0) {
|
||||
// 같은 멱등키의 재시도. 모든 행을 한 트랜잭션에서 썼으므로 있는 것이 전부다.
|
||||
return prior.map((s) => ({
|
||||
txId: s.id,
|
||||
popType: s.get('popType') as PopType,
|
||||
amount: Math.abs((s.get('amount') as number | undefined) ?? 0),
|
||||
balanceAfter: (s.get('balanceAfter') as number | undefined) ?? 0,
|
||||
lotRefs: (s.get('lotRefs') as LotRef[] | undefined) ?? [],
|
||||
}));
|
||||
}
|
||||
|
||||
await input.precheck?.(tx);
|
||||
const wallet = await readWallet(tx, db, input.uid);
|
||||
// 음수 잔액(대사 전의 어긋난 지갑)을 분할 몫으로 쓰지 않는다.
|
||||
const balanceOf = (t: PopType) =>
|
||||
Math.max(0, t === 'activity' ? wallet.activityBalance : wallet.supportBalance);
|
||||
if (types.reduce((sum, t) => sum + balanceOf(t), 0) < input.amount) {
|
||||
fail(ERR.popInsufficient, 'POP 잔액이 부족합니다.');
|
||||
}
|
||||
|
||||
// 앞 타입부터 채우고 부족분만 다음 타입(B-06).
|
||||
let need = input.amount;
|
||||
const plan = types.map((popType, i) => {
|
||||
const amount = Math.min(balanceOf(popType), need);
|
||||
need -= amount;
|
||||
return { popType, amount, txId: txIds[i]!, ref: txRefs[i]! };
|
||||
}).filter((p) => p.amount > 0);
|
||||
|
||||
// 기여 차감이면 EXP·FPI 귀속을 정해야 하므로 사용자 문서를 함께 읽는다.
|
||||
// 트랜잭션은 모든 읽기가 쓰기보다 앞서야 하므로 여기서 읽어 둔다.
|
||||
const userSnap = CONTRIBUTION_REASONS.includes(input.reasonCode)
|
||||
? await tx.get(db.doc(`users/${input.uid}`))
|
||||
: null;
|
||||
|
||||
const lotsSnaps = await Promise.all(plan.map((p) => tx.get(
|
||||
db.collection(`users/${input.uid}/popLots`)
|
||||
.where('popType', '==', p.popType)
|
||||
.where('status', '==', 'active')
|
||||
.orderBy('grantedAt', 'asc'),
|
||||
)));
|
||||
|
||||
const parts: SpendPart[] = [];
|
||||
const lotWrites: Array<[DocumentReference, number]> = [];
|
||||
plan.forEach((p, i) => {
|
||||
let left = p.amount;
|
||||
const lotRefs: LotRef[] = [];
|
||||
for (const doc of lotsSnaps[i]!.docs) {
|
||||
if (left === 0) break;
|
||||
const remaining = (doc.get('remaining') as number | undefined) ?? 0;
|
||||
if (remaining <= 0) continue;
|
||||
const take = Math.min(remaining, left);
|
||||
left -= take;
|
||||
lotRefs.push({ lotId: doc.id, delta: -take });
|
||||
lotWrites.push([doc.ref, remaining - take]);
|
||||
}
|
||||
if (left > 0) {
|
||||
// 지갑 잔액은 충분한데 lot 합이 모자란 상태. 원장이 이미 어긋나 있다는 뜻이라
|
||||
// 차감을 진행하지 않고 대사 대상으로 표시한다 — 자동 정정은 원인을 덮는다(§3.6).
|
||||
mismatch = true;
|
||||
fail(ERR.popVerifyFailed, 'POP 잔액과 획득 내역이 일치하지 않습니다.');
|
||||
}
|
||||
parts.push({
|
||||
txId: p.txId, popType: p.popType, amount: p.amount,
|
||||
balanceAfter: balanceOf(p.popType) - p.amount, lotRefs,
|
||||
});
|
||||
});
|
||||
|
||||
for (const [ref, remaining] of lotWrites) {
|
||||
tx.update(ref, {
|
||||
remaining,
|
||||
status: remaining === 0 ? 'exhausted' : 'active',
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
});
|
||||
}
|
||||
|
||||
parts.forEach((part, i) => {
|
||||
tx.create(plan[i]!.ref, {
|
||||
type: 'spend',
|
||||
popType: part.popType,
|
||||
// 부호 있는 값 — 차감은 음수(§3.3). 합산만으로 잔액이 재현되어야 한다.
|
||||
amount: -part.amount,
|
||||
reasonCode: input.reasonCode,
|
||||
// 분할 차감의 두 행은 같은 멱등키를 적는다 — 한 요청이었음을 원장에서 묶어 본다.
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
...(input.refPath ? { refPath: input.refPath } : {}),
|
||||
// 묶음 횟수. 없으면 적지 않는다 — 읽는 쪽이 「없으면 1 회」로 셀 수 있다.
|
||||
...(input.count !== undefined ? { count: input.count } : {}),
|
||||
lotRefs: part.lotRefs,
|
||||
balanceAfter: part.balanceAfter,
|
||||
serverVerified: true,
|
||||
// 규범 확정(B-06) 뒤의 행임을 남긴다. 과거 행은 'grantedAt-asc-nonnormative'.
|
||||
lotOrder: 'general-first-fifo',
|
||||
createdAt: FieldValue.serverTimestamp(),
|
||||
});
|
||||
tx.set(wallet.ref, {
|
||||
[balanceField(part.popType)]: FieldValue.increment(-part.amount),
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
}, { merge: true });
|
||||
});
|
||||
|
||||
// Zap 의 귀속 팬덤은 **서버가 이 트랜잭션에서 읽은 대표 팬덤**이다(FPI 와 같은 판정).
|
||||
// 클라이언트 `fandomId` 를 믿으면 남의 행성 성장을 올린다. 후원은 호출자(fundCampaign)가
|
||||
// 캠페인 문서에서 정한 팬덤을 넘긴다.
|
||||
const fandomId = input.reasonCode === 'ZAP_CONTRIBUTION' ? fandomOfUser(userSnap) : input.fandomId;
|
||||
if (CONTRIBUTION_REASONS.includes(input.reasonCode)) {
|
||||
// 누적 기여 POP 은 Passport 입력 · 기여 실적 표시용으로 유지한다. 등급 문턱은
|
||||
// 더 이상 여기서 매기지 않는다(C-18 — 누적 기여 POP 5등급 치환 폐기).
|
||||
tx.set(db.doc(`users/${input.uid}`), {
|
||||
totalContributedPop: FieldValue.increment(input.amount),
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
}, { merge: true });
|
||||
if (fandomId) {
|
||||
tx.set(db.doc(`fandoms/${fandomId}/members/${input.uid}`), {
|
||||
contributedPop: FieldValue.increment(input.amount),
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
}, { merge: true });
|
||||
}
|
||||
}
|
||||
|
||||
// 예치(QNA_BOUNTY) · 등록비(CAMPAIGN_FEE) 등 다른 차감은 성장 · EXP · FPI 로 확대하지
|
||||
// 않는다(C-30). 기여 POP 으로 냈어도 같다 — 원천을 가르는 것은 재화가 아니라 사유다.
|
||||
const growthSpend = input.reasonCode === 'ZAP_CONTRIBUTION' || input.reasonCode === 'CAMPAIGN_FUND';
|
||||
const sourceId = parts[0]!.txId;
|
||||
if (growthSpend) {
|
||||
// FPI 는 기여 **행위 단위**다(C-34). 금액이 아니라 Zap 횟수(`fn-pop-spend` 가 서버에서
|
||||
// 금액 ÷ zapPop 으로 계산) · 후원 1건. EXP 는 주지 않는다 — POP 을 쓰는 행위는 1 POP
|
||||
// 단위로 쪼개 반복할 수 있어 건당 EXP 가 파밍된다(C-18 · 리뷰 B1).
|
||||
writeFpiEvent(tx, {
|
||||
uid: input.uid, fandomId: fandomOfUser(userSnap), axis: 'contribution',
|
||||
units: input.reasonCode === 'ZAP_CONTRIBUTION' ? input.count ?? 1 : 1,
|
||||
activity: input.reasonCode === 'ZAP_CONTRIBUTION' ? 'zap' : 'campaign_fund',
|
||||
sourceId, occurredAt: new Date(),
|
||||
...(input.refPath ? { refPath: input.refPath } : {}),
|
||||
grantsExp: false,
|
||||
}, userSnap);
|
||||
}
|
||||
if (fandomId && growthSpend) {
|
||||
writeGrowth(tx, db, input.uid, sourceId, fandomId, input.amount,
|
||||
input.reasonCode === 'ZAP_CONTRIBUTION' ? 'zap' : 'funding');
|
||||
tx.set(db.doc(`fandoms/${fandomId}`), {
|
||||
lastContributorName: publicNameOf(userSnap),
|
||||
lastContributedPop: input.amount,
|
||||
}, { merge: true });
|
||||
}
|
||||
|
||||
input.alsoWrite?.(tx, parts);
|
||||
return parts;
|
||||
}
|
||||
}
|
||||
|
||||
type RefundReason = Extract<ReasonCode,
|
||||
'CAMPAIGN_REFUND' | 'QNA_REFUND' | 'CAMPAIGN_FEE_REFUND' | 'QUEST_POOL_REFUND'>;
|
||||
|
||||
/**
|
||||
* 환불 — `fn-pop-refund` 의 코어.
|
||||
*
|
||||
* 원 거래를 되돌리는 것이 아니라 **반대 부호의 새 원장**을 쓴다. 원장이 진실이라는
|
||||
* 원칙(§3.3)을 지키려면 과거 행이 그대로 남아 있어야 한다. 다른 멱등키로 이미 환불된
|
||||
* 원거래는 E-POP-03 이다 — 호출자가 그 환불(`refundedBy`)을 받아들일지 정한다.
|
||||
*/
|
||||
export async function refundPop(params: {
|
||||
uid: string;
|
||||
originalTxId: string;
|
||||
idempotencyKey: string;
|
||||
reasonCode: RefundReason;
|
||||
}): Promise<LedgerResult> {
|
||||
const [result] = await refundParts(
|
||||
params.uid, [params.originalTxId], params.idempotencyKey, params.reasonCode, false);
|
||||
return result!;
|
||||
}
|
||||
|
||||
/**
|
||||
* 분할 차감([spendSplit])의 환불 — 행마다 **원래 타입 그대로** 돌린다(B-39 「원타입 환불」).
|
||||
*
|
||||
* 모든 행을 한 트랜잭션에서 돌려주므로 일부만 환불된 상태가 남지 않는다. 멱등키 하나가
|
||||
* 전체를 묶는다 — 첫 행은 `txIdOf(key)`, 나머지는 `txIdOf(key:원거래ID)`. 같은 원거래 ID 는
|
||||
* 한 번만 센다. 다른 경로가 이미 돌려준 행은 다시 돌려주지 않고 그 환불 ID 를 결과로 쓴다.
|
||||
*/
|
||||
export async function refundSplit(params: {
|
||||
uid: string;
|
||||
originalTxIds: string[];
|
||||
idempotencyKey: string;
|
||||
reasonCode: RefundReason;
|
||||
}): Promise<LedgerResult[]> {
|
||||
return refundParts(params.uid, params.originalTxIds, params.idempotencyKey, params.reasonCode, true);
|
||||
}
|
||||
|
||||
async function refundParts(
|
||||
uid: string, originalTxIds: string[], idempotencyKey: string, reasonCode: RefundReason,
|
||||
reuseRefunded: boolean,
|
||||
): Promise<LedgerResult[]> {
|
||||
const ids = [...new Set(originalTxIds)];
|
||||
if (ids.length === 0) fail(ERR.popVerifyFailed, '환불할 원거래가 없습니다.');
|
||||
const db = getFirestore();
|
||||
const txIds = ids.map((orig, i) =>
|
||||
txIdOf(i === 0 ? idempotencyKey : `${idempotencyKey}:${orig}`));
|
||||
const rowOf = (s: FirebaseFirestore.DocumentSnapshot): LedgerResult => ({
|
||||
txId: s.id,
|
||||
balanceAfter: (s.get('balanceAfter') as number | undefined) ?? 0,
|
||||
lotRefs: (s.get('lotRefs') as LotRef[] | undefined) ?? [],
|
||||
});
|
||||
|
||||
return db.runTransaction(async (tx) => {
|
||||
const txRefs = txIds.map((id) => db.doc(`users/${uid}/popTransactions/${id}`));
|
||||
const originalRefs = ids.map((id) => db.doc(`users/${uid}/popTransactions/${id}`));
|
||||
const [own, originals] = await Promise.all([
|
||||
Promise.all(txRefs.map((ref) => tx.get(ref))),
|
||||
Promise.all(originalRefs.map((ref) => tx.get(ref))),
|
||||
]);
|
||||
|
||||
// 이 키로 이미 쓴 행은 그대로(재시도), 다른 환불이 먼저 간 행은 그 환불을 쓴다.
|
||||
const results: Array<LedgerResult | null> = own.map((s) => (s.exists ? rowOf(s) : null));
|
||||
const todo: number[] = [];
|
||||
for (const [i, original] of originals.entries()) {
|
||||
if (results[i]) continue;
|
||||
if (!original.exists) {
|
||||
fail(ERR.popVerifyFailed, '환불할 원거래를 찾을 수 없습니다.');
|
||||
}
|
||||
if (original.get('type') !== 'spend') {
|
||||
fail(ERR.popVerifyFailed, '차감 거래만 환불할 수 있습니다.');
|
||||
}
|
||||
const refundedBy = original.get('refundedBy') as string | undefined;
|
||||
if (!refundedBy) {
|
||||
todo.push(i);
|
||||
} else if (!reuseRefunded) {
|
||||
fail(ERR.popDuplicate, '이미 환불된 거래입니다.');
|
||||
} else {
|
||||
results[i] = rowOf(await tx.get(db.doc(`users/${uid}/popTransactions/${refundedBy}`)));
|
||||
}
|
||||
}
|
||||
if (todo.length === 0) return results as LedgerResult[];
|
||||
|
||||
const wallet = await readWallet(tx, db, uid);
|
||||
const running = { activity: wallet.activityBalance, support: wallet.supportBalance };
|
||||
|
||||
let contributed = 0;
|
||||
for (const i of todo) {
|
||||
const original = originals[i]!;
|
||||
const popType = original.get('popType') as PopType;
|
||||
const amount = Math.abs(original.get('amount') as number);
|
||||
running[popType] += amount;
|
||||
const balanceAfter = running[popType];
|
||||
const txId = txIds[i]!;
|
||||
const lotId = lotIdOf(txId);
|
||||
|
||||
tx.create(db.doc(`users/${uid}/popLots/${lotId}`), {
|
||||
popType,
|
||||
grantedAmount: amount,
|
||||
remaining: amount,
|
||||
grantedAt: FieldValue.serverTimestamp(),
|
||||
status: 'active',
|
||||
sourceTxId: txId,
|
||||
reasonCode,
|
||||
createdAt: FieldValue.serverTimestamp(),
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
});
|
||||
|
||||
tx.create(txRefs[i]!, {
|
||||
type: 'refund',
|
||||
popType,
|
||||
amount,
|
||||
reasonCode,
|
||||
idempotencyKey,
|
||||
refPath: originalRefs[i]!.path,
|
||||
lotRefs: [{ lotId, delta: amount }],
|
||||
balanceAfter,
|
||||
serverVerified: true,
|
||||
createdAt: FieldValue.serverTimestamp(),
|
||||
});
|
||||
|
||||
// 원 거래에는 환불 사실만 표시한다. 금액·부호는 손대지 않는다(append-only).
|
||||
tx.update(originalRefs[i]!, { refundedBy: txId });
|
||||
|
||||
tx.set(wallet.ref, {
|
||||
[balanceField(popType)]: FieldValue.increment(amount),
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
}, { merge: true });
|
||||
|
||||
if (CONTRIBUTION_REASONS.includes(original.get('reasonCode') as ReasonCode)) contributed += amount;
|
||||
results[i] = { txId, balanceAfter, lotRefs: [{ lotId, delta: amount }] };
|
||||
}
|
||||
|
||||
if (contributed > 0) {
|
||||
// 반환은 Growth · EXP 를 만들지도 되돌리지도 않는다(C-30 · C-32).
|
||||
tx.set(db.doc(`users/${uid}`), {
|
||||
totalContributedPop: FieldValue.increment(-contributed),
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
}, { merge: true });
|
||||
}
|
||||
|
||||
return results as LedgerResult[];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 성장 역분개 — 정책 원장 **C-32**.
|
||||
*
|
||||
* 허위 참여 · 중복 지급 · 오지급이 **확정**된 뒤 운영자가 부르는 경로다. 원 growth
|
||||
* 이벤트를 참조하는 음수 행을 새로 쓰고 `fandoms.growthPop` 을 그만큼 줄인다(append-only).
|
||||
* 잘못 반영된 몫만 정정하므로 `amount` 는 원 이벤트 이하다. 원 이벤트 하나에 역분개는
|
||||
* 한 번뿐이다(문서 ID `rev-{원 이벤트}`) — 재시도는 기존 결과를 돌려준다.
|
||||
*
|
||||
* Quest 종료 · 취소 · 의심 단계는 이 경로를 부르지 않는다. 지갑 회수 · EXP · 과거
|
||||
* FPI/sFPI 재산정과도 분리한다.
|
||||
*/
|
||||
export async function reverseGrowth(params: {
|
||||
uid: string; sourceEventId: string; amount: number; reason: string; actorUid: string;
|
||||
}): Promise<{ reversalId: string; fandomId: string; amount: number }> {
|
||||
const db = getFirestore();
|
||||
const reversalId = `rev-${params.sourceEventId}`;
|
||||
return db.runTransaction(async (tx) => {
|
||||
const originalRef = db.doc(`users/${params.uid}/growthEvents/${params.sourceEventId}`);
|
||||
const reversalRef = db.doc(`users/${params.uid}/growthEvents/${reversalId}`);
|
||||
const [original, existing] = await Promise.all([tx.get(originalRef), tx.get(reversalRef)]);
|
||||
if (existing.exists) {
|
||||
return {
|
||||
reversalId,
|
||||
fandomId: existing.get('fandomIdAtPayout') as string,
|
||||
amount: -(existing.get('amount') as number),
|
||||
};
|
||||
}
|
||||
const originalAmount = original.get('amount');
|
||||
const fandomId = original.get('fandomIdAtPayout');
|
||||
if (!original.exists || original.get('reversalOf') != null ||
|
||||
typeof originalAmount !== 'number' || originalAmount <= 0 || typeof fandomId !== 'string') {
|
||||
fail(ERR.popVerifyFailed, '역분개할 성장 기록을 찾을 수 없습니다.');
|
||||
}
|
||||
if (!Number.isInteger(params.amount) || params.amount <= 0 || params.amount > originalAmount) {
|
||||
fail(ERR.popVerifyFailed, '역분개 수치는 원 성장량 이하의 양의 정수여야 합니다.');
|
||||
}
|
||||
// Quest 보상 전액 역분개 = 그 완료의 사후 무효 → 원장 행에 걸린 검증 활동 표식을 무효로
|
||||
// (Campaign 개설자격 A안). 일부 정정은 금액 오류라 완료 자체는 유효하다. 읽기라 쓰기보다 앞선다.
|
||||
const voidWrite = original.get('sourceType') === 'quest_reward' && params.amount === originalAmount
|
||||
? await voidCompletions(tx, params.uid, { txId: original.get('sourceEventId') ?? params.sourceEventId })
|
||||
: null;
|
||||
tx.create(reversalRef, {
|
||||
sourceEventId: original.get('sourceEventId') ?? params.sourceEventId,
|
||||
sourceType: original.get('sourceType'),
|
||||
fandomIdAtPayout: fandomId,
|
||||
amount: -params.amount,
|
||||
reversalOf: params.sourceEventId,
|
||||
reason: params.reason,
|
||||
actorUid: params.actorUid,
|
||||
createdAt: FieldValue.serverTimestamp(),
|
||||
});
|
||||
tx.set(db.doc(`fandoms/${fandomId}`), {
|
||||
growthPop: FieldValue.increment(-params.amount),
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
}, { merge: true });
|
||||
voidWrite?.(tx);
|
||||
return { reversalId, fandomId, amount: params.amount };
|
||||
});
|
||||
}
|
||||
|
||||
/** 대사 결과 한 사용자분(§3.6). */
|
||||
export interface ReconcileRow {
|
||||
uid: string;
|
||||
ok: boolean;
|
||||
checks: Array<{ name: string; expected: number; actual: number }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 잔액 대조 — 별첨 1 ② 검수 방법의 3자 교차 대조.
|
||||
*
|
||||
* **자동으로 고치지 않는다.** 감지하고 표시만 한다 — 자동 정정은 원인을 덮는다.
|
||||
*/
|
||||
export async function reconcileUser(uid: string): Promise<ReconcileRow> {
|
||||
const db = getFirestore();
|
||||
const [walletSnap, lotsSnap, txSnap, userSnap] = await Promise.all([
|
||||
walletRef(db, uid).get(),
|
||||
db.collection(`users/${uid}/popLots`).where('status', '==', 'active').get(),
|
||||
db.collection(`users/${uid}/popTransactions`).get(),
|
||||
db.doc(`users/${uid}`).get(),
|
||||
]);
|
||||
|
||||
const wallet = walletSnap.data() ?? {};
|
||||
const checks: ReconcileRow['checks'] = [];
|
||||
|
||||
for (const t of ['activity', 'support'] as const) {
|
||||
const lotSum = lotsSnap.docs
|
||||
.filter((d) => d.get('popType') === t)
|
||||
.reduce((acc, d) => acc + ((d.get('remaining') as number) ?? 0), 0);
|
||||
const txSum = txSnap.docs
|
||||
.filter((d) => d.get('popType') === t && d.get('pending') !== true)
|
||||
.reduce((acc, d) => acc + ((d.get('amount') as number) ?? 0), 0);
|
||||
const balance = (wallet[balanceField(t)] as number | undefined) ?? 0;
|
||||
checks.push({ name: `${t}:balance-vs-lot`, expected: lotSum, actual: balance });
|
||||
checks.push({ name: `${t}:balance-vs-ledger`, expected: txSum, actual: balance });
|
||||
}
|
||||
|
||||
const contributionSpends = txSnap.docs.filter((d) =>
|
||||
d.get('type') === 'spend' &&
|
||||
CONTRIBUTION_REASONS.includes(d.get('reasonCode') as ReasonCode));
|
||||
const contributed = contributionSpends
|
||||
.reduce((acc, d) => acc + Math.abs((d.get('amount') as number) ?? 0), 0);
|
||||
// 기여가 아닌 차감(등록비 등)의 환불은 누적 기여를 줄이지 않는다 — refundParts 와 같은 판정.
|
||||
const contributionPaths = new Set(contributionSpends.map((d) => d.ref.path));
|
||||
const refunded = txSnap.docs
|
||||
.filter((d) => d.get('type') === 'refund' && contributionPaths.has(d.get('refPath') as string))
|
||||
.reduce((acc, d) => acc + ((d.get('amount') as number) ?? 0), 0);
|
||||
const statsActual =
|
||||
(userSnap.get('totalContributedPop') as number | undefined) ?? 0;
|
||||
checks.push({
|
||||
name: 'ledger-vs-stats',
|
||||
expected: contributed - refunded,
|
||||
actual: statsActual,
|
||||
});
|
||||
|
||||
// User Level ↔ EXP, Passport Tier 단조(설계서 §3.4 · B-65 · B-66). 두 체계를 따로 본다.
|
||||
const storedLevel = userSnap.get('userLevel') as number | undefined;
|
||||
if (storedLevel !== undefined) {
|
||||
checks.push({
|
||||
name: 'level-vs-exp',
|
||||
expected: levelOf((userSnap.get('userExp') as number | undefined) ?? 0),
|
||||
actual: storedLevel,
|
||||
});
|
||||
}
|
||||
const storedPassport = userSnap.get('passportTier') as PassportTier | undefined;
|
||||
if (storedPassport !== undefined) {
|
||||
// 레벨이 요구하는 등급보다 낮게 저장돼 있으면(강등 · 누락) 불일치다.
|
||||
checks.push({
|
||||
name: 'passport-monotonic',
|
||||
// 레벨 위조가 이 검사까지 오염시키지 않도록 저장된 레벨이 아니라 EXP 로 본다.
|
||||
expected: passportRank(passportTierOf(
|
||||
levelOf((userSnap.get('userExp') as number | undefined) ?? 0), storedPassport)),
|
||||
actual: passportRank(storedPassport),
|
||||
});
|
||||
}
|
||||
|
||||
const ok = checks.every((c) => c.expected === c.actual);
|
||||
await walletRef(db, uid).set({
|
||||
lastReconciledAt: Timestamp.now(),
|
||||
reconcileStatus: ok ? 'ok' : 'mismatch',
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
}, { merge: true });
|
||||
|
||||
return { uid, ok, checks };
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { onCall } from 'firebase-functions/v2/https';
|
||||
import { onSchedule } from 'firebase-functions/v2/scheduler';
|
||||
import { getFirestore } from 'firebase-admin/firestore';
|
||||
import { logger } from 'firebase-functions';
|
||||
|
||||
import { callerUid, num, optionalStr, requireRole, str } from '../core/callable.js';
|
||||
import { ERR, fail, positiveInt } from '../core/errors.js';
|
||||
import { loadPolicy } from '../core/config.js';
|
||||
import {
|
||||
grantPop, refundPop, reconcileUser, spendPop,
|
||||
type PopType, type ReasonCode,
|
||||
} from './ledger.js';
|
||||
|
||||
const POP_TYPES: readonly PopType[] = ['activity', 'support'];
|
||||
|
||||
function popType(data: unknown): PopType {
|
||||
const value = str(data, 'popType');
|
||||
if (!POP_TYPES.includes(value as PopType)) {
|
||||
fail(ERR.popVerifyFailed, 'popType 은 activity 또는 support 여야 합니다.');
|
||||
}
|
||||
return value as PopType;
|
||||
}
|
||||
|
||||
/**
|
||||
* `fn-pop-grant` — 관리자 지급 전용 입구.
|
||||
*
|
||||
* 사용자 스스로 부르는 지급 경로는 두지 않았다. 퀘스트·광고 보상은 각자의 검증
|
||||
* 함수를 거쳐야 하고, 이 함수를 사용자에게 열면 별첨1 ② 「클라이언트 단독 지급
|
||||
* 0건」이 코드 층에서 깨진다.
|
||||
*/
|
||||
export const fnPopGrant = onCall(async (req) => {
|
||||
const actorUid = callerUid(req);
|
||||
requireRole(req, ['master', 'ops']);
|
||||
const result = await grantPop({
|
||||
uid: str(req.data, 'uid'),
|
||||
popType: popType(req.data),
|
||||
amount: positiveInt(num(req.data, 'amount'), 'amount'),
|
||||
reasonCode: str(req.data, 'reasonCode') as ReasonCode,
|
||||
idempotencyKey: str(req.data, 'idempotencyKey'),
|
||||
actorUid,
|
||||
...(optionalStr(req.data, 'refPath') ? { refPath: str(req.data, 'refPath') } : {}),
|
||||
});
|
||||
return { txId: result.txId, balanceAfter: result.balanceAfter };
|
||||
});
|
||||
|
||||
/**
|
||||
* 사용자가 직접 부를 수 있는 차감 사유 — **허용 목록**.
|
||||
*
|
||||
* 다른 사유는 각자의 검증 함수를 지나야 한다. 예전에는 사유를 그대로 받아 `CAMPAIGN_FUND`
|
||||
* 를 캠페인 검증 없이 쓰거나(B-33 · B-34 우회) 원장 사유를 위조할 수 있었다(리뷰 H1 · R03).
|
||||
*/
|
||||
const USER_SPEND_REASONS: readonly ReasonCode[] = ['ZAP_CONTRIBUTION'];
|
||||
|
||||
/**
|
||||
* `fn-pop-spend` — 본인 계정에서만 차감한다. 지금 열린 사유는 Zap 하나다.
|
||||
*
|
||||
* **Zap 횟수는 서버가 센다** — `금액 ÷ zapPop`(운영값). 클라이언트 `count` 를 믿으면
|
||||
* 1 POP 으로 FPI CONTRIBUTION 축을 무제한 올린다(리뷰 B1). 귀속 팬덤도 원장이 사용자
|
||||
* 문서에서 정한다 — 클라이언트 `fandomId` 는 받지 않는다(리뷰 M1).
|
||||
*/
|
||||
export const fnPopSpend = onCall(async (req) => {
|
||||
const uid = callerUid(req);
|
||||
const reasonCode = str(req.data, 'reasonCode') as ReasonCode;
|
||||
if (!USER_SPEND_REASONS.includes(reasonCode)) {
|
||||
fail(ERR.permissionDenied, '이 경로로 쓸 수 없는 사유입니다.');
|
||||
}
|
||||
const amount = positiveInt(num(req.data, 'amount'), 'amount');
|
||||
const { zapPop } = await loadPolicy();
|
||||
const count = Math.floor(amount / zapPop);
|
||||
if (!Number.isSafeInteger(count) || count < 1 || count * zapPop !== amount) {
|
||||
fail(ERR.popVerifyFailed, `Zap 은 ${zapPop} POP 단위로만 보낼 수 있습니다.`);
|
||||
}
|
||||
return spendPop({
|
||||
uid,
|
||||
popType: popType(req.data),
|
||||
amount,
|
||||
reasonCode,
|
||||
// 사용자가 정한 키는 `user:{uid}:` 아래에 둔다. 그대로 쓰면 서버 경로의 키
|
||||
// (`bounty:…` · `campaignFee:…`)를 먼저 차지해 그 거래를 막을 수 있다.
|
||||
idempotencyKey: `user:${uid}:${str(req.data, 'idempotencyKey')}`,
|
||||
...(optionalStr(req.data, 'refPath') ? { refPath: str(req.data, 'refPath') } : {}),
|
||||
// 묶음 횟수. 원장 행에 남아 「Zap N 회」를 세고, FPI 단위가 된다.
|
||||
count,
|
||||
});
|
||||
});
|
||||
|
||||
/** `fn-pop-refund` — 운영자만. 캠페인 실패·현상금 만료의 되돌림 경로다. */
|
||||
export const fnPopRefund = onCall(async (req) => {
|
||||
callerUid(req);
|
||||
requireRole(req, ['master', 'ops']);
|
||||
const result = await refundPop({
|
||||
uid: str(req.data, 'uid'),
|
||||
originalTxId: str(req.data, 'txId'),
|
||||
idempotencyKey: str(req.data, 'idempotencyKey'),
|
||||
reasonCode: str(req.data, 'reasonCode') as 'CAMPAIGN_REFUND' | 'QNA_REFUND',
|
||||
});
|
||||
return { txId: result.txId, balanceAfter: result.balanceAfter };
|
||||
});
|
||||
|
||||
/**
|
||||
* `fn-pop-reconcile` — 일 1회 3자 교차 대조(§3.6).
|
||||
*
|
||||
* 불일치를 **고치지 않고 표시만** 한다. 사용자 수가 늘면 이 전수 스캔은
|
||||
* 샤딩해야 하지만, 대조 자체는 계약 검수 항목이라 먼저 동작하게 두었다.
|
||||
*/
|
||||
export const fnPopReconcile = onSchedule('every day 04:00', async () => {
|
||||
const db = getFirestore();
|
||||
const users = await db.collection('users').select().get();
|
||||
let mismatched = 0;
|
||||
for (const doc of users.docs) {
|
||||
const row = await reconcileUser(doc.id);
|
||||
if (!row.ok) {
|
||||
mismatched += 1;
|
||||
logger.error('[reconcile] 원장 불일치', { uid: row.uid, checks: row.checks });
|
||||
}
|
||||
}
|
||||
logger.info('[reconcile] 완료', { scanned: users.size, mismatched });
|
||||
});
|
||||
Reference in New Issue
Block a user