mmday-firebase/src/services/rewardSettlementService.ts
윤정민 c964572a9c Replace point ledger with wallet engine and rework reward earning
- 지갑 문서(users/{uid}/wallet/current)와 문서 ID=멱등키 원장(pointLedger/{txId})으로 포인트 엔진 교체 — 기존 balanceAfter 최신 row 조회 방식 폐기
- 모든 포인트 변경은 pointService.applyPointChangesTx 단일 경로로 처리, available+reserved == totalEarned-totalSpent 불변식을 매 커밋 검증
- 출석 리워드 개편: 일일 20P, 연속 5일 +50P(사이클당 1회), 10일 단위 +100P — attendance/state 문서에 스트릭 상태 저장, 주간·월간 보너스 폐기
- 승부예측 일일 리워드 정산 신설: 전체 참여 50P + 성공 100P + 퍼펙트 50P, judgeDay 이후 voteHistory.rewardSettledAt 플래그와 원장 멱등키로 배치 재실행에도 중복 지급 차단
- 관리자 포인트 지급·회수(adminPointService)와 수동 재정산 디버그 라우트(/debug/settle-reward) 추가
- 소비처 없던 티켓 시스템(dailyAllKill·weeklyMaster)과 위클리마스터 판정 흐름 전체 제거 — StatsResponse.tickets 필드 삭제로 클라 응답 스키마 변경
2026-07-16 14:25:42 +09:00

34 lines
2.7 KiB
TypeScript

import { Timestamp } from "firebase-admin/firestore";
import { firestore } from "../firebase";
import { PREDICTION_PARTICIPATION_POINTS, PREDICTION_PERFECT_BONUS_POINTS, PREDICTION_SUCCESS_POINTS } from "../constants/points";
import { listByDate } from "../repositories/gameRepository";
import { PointLedgerType, type VoteHistoryDoc } from "../types/panit";
import type { DateString } from "../types/dateString";
import type { PointChange } from "../types/points";
import { applyPointChangesTx, isAlreadyExistsError } from "./pointService";
export type SettlementResult = "settled" | "no_history" | "already_settled" | "not_judged";
export async function settleDailyReward(uid: string, date: DateString): Promise<{ result: SettlementResult; total: number }> {
const eligible = (await listByDate(date)).filter((g) => g.status === "completed"); const ref = firestore.doc(`users/${uid}/voteHistory/${date}`);
try {
return await firestore.runTransaction(async (tx) => {
const snap = await tx.get(ref); if (!snap.exists) return { result: "no_history" as const, total: 0 };
const history = snap.data() as VoteHistoryDoc; if (history.rewardSettledAt) return { result: "already_settled" as const, total: history.rewardTotal ?? 0 };
if (!history.judgment) return { result: "not_judged" as const, total: 0 };
const voted = new Set(history.data.filter((v) => !v.cancelled).map((v) => v.gameId));
const full = eligible.length > 0 && eligible.every((g) => voted.has(g.gameId)); const changes: PointChange[] = [];
if (full) {
changes.push({ txId: `${uid}:${date}:prediction_daily_participation`, type: PointLedgerType.PredictionDailyParticipation, amount: PREDICTION_PARTICIPATION_POINTS, relatedDate: date });
if (history.judgment === "success" || history.judgment === "perfect") changes.push({ txId: `${uid}:${date}:prediction_daily_success`, type: PointLedgerType.PredictionDailySuccess, amount: PREDICTION_SUCCESS_POINTS, relatedDate: date });
if (history.judgment === "perfect") changes.push({ txId: `${uid}:${date}:prediction_daily_perfect`, type: PointLedgerType.PredictionDailyPerfect, amount: PREDICTION_PERFECT_BONUS_POINTS, relatedDate: date });
}
const total = changes.reduce((n, c) => n + c.amount, 0); if (total) await applyPointChangesTx(tx, uid, changes);
tx.set(ref, { rewardSettledAt: Timestamp.now(), rewardTotal: total }, { merge: true }); return { result: "settled" as const, total };
});
} catch (err) {
if (!isAlreadyExistsError(err)) throw err;
const snap = await ref.get(); const total = (snap.data() as VoteHistoryDoc | undefined)?.rewardTotal ?? 0;
await ref.set({ rewardSettledAt: Timestamp.now(), rewardTotal: total }, { merge: true }); return { result: "already_settled", total };
}
}