mmday-firebase/src/services/pointService.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

37 lines
2.7 KiB
TypeScript

import { Timestamp, type Transaction } from "firebase-admin/firestore";
import { firestore } from "../firebase";
import { HttpError } from "../middleware/errors";
import { createLedgerEntryTx } from "../repositories/pointLedgerRepository";
import { getWalletTx, walletDocRef } from "../repositories/walletRepository";
import { OP_BY_TYPE, PointLedgerType, type PointChange, type PointLedgerEntry, type WalletDoc } from "../types/points";
const TX_ID = /^[A-Za-z0-9:_-]{1,240}$/;
export function isAlreadyExistsError(err: unknown): boolean { const code = (err as { code?: unknown })?.code; return code === 6 || code === "already-exists" || code === "ALREADY_EXISTS"; }
export async function applyPointChangesTx(tx: Transaction, uid: string, changes: PointChange[]): Promise<WalletDoc | null> {
for (const c of changes) if (!TX_ID.test(c.txId) || !Number.isSafeInteger(c.amount) || c.amount <= 0) throw new HttpError(400, "invalid point change", "INVALID_POINT_CHANGE");
const existing = await getWalletTx(tx, uid);
if (changes.length === 0) return existing;
const now = Timestamp.now();
const wallet: WalletDoc = existing ? { ...existing } : { availableBalance: 0, reservedBalance: 0, totalEarned: 0, totalSpent: 0, version: 0, createdAt: now, updatedAt: now };
for (const c of changes) {
const beforeA = wallet.availableBalance; const beforeR = wallet.reservedBalance; const op = OP_BY_TYPE[c.type];
if (op === "credit") {
wallet.availableBalance += c.amount;
if (c.type === PointLedgerType.OrderRefund) wallet.totalSpent -= c.amount;
else wallet.totalEarned += c.amount;
}
if (op === "debit") { wallet.availableBalance -= c.amount; wallet.totalSpent += c.amount; }
if (op === "reserve") { wallet.availableBalance -= c.amount; wallet.reservedBalance += c.amount; }
if (op === "capture") { wallet.reservedBalance -= c.amount; wallet.totalSpent += c.amount; }
if (op === "release") { wallet.reservedBalance -= c.amount; wallet.availableBalance += c.amount; }
if (wallet.availableBalance < 0 || wallet.reservedBalance < 0) throw new HttpError(409, "insufficient balance", "INSUFFICIENT_BALANCE");
const entry: PointLedgerEntry = { ...c, uid, op, availableBefore: beforeA, availableAfter: wallet.availableBalance, reservedBefore: beforeR, reservedAfter: wallet.reservedBalance, createdAt: now };
createLedgerEntryTx(tx, uid, entry);
}
if (wallet.availableBalance + wallet.reservedBalance !== wallet.totalEarned - wallet.totalSpent) throw new Error("wallet invariant violated");
wallet.version += 1; wallet.updatedAt = now;
tx.set(walletDocRef(uid), wallet);
return wallet;
}
export async function applyPointChanges(uid: string, changes: PointChange[]) { return firestore.runTransaction((tx) => applyPointChangesTx(tx, uid, changes)); }