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 필드 삭제로 클라 응답 스키마 변경
This commit is contained in:
윤정민 2026-07-16 14:25:42 +09:00
parent 9daba193e4
commit c964572a9c
19 changed files with 252 additions and 842 deletions

6
src/constants/points.ts Normal file
View File

@ -0,0 +1,6 @@
export const ATTENDANCE_DAILY_POINTS = 20;
export const ATTENDANCE_STREAK_5_POINTS = 50;
export const ATTENDANCE_STREAK_10_INTERVAL_POINTS = 100;
export const PREDICTION_PARTICIPATION_POINTS = 50;
export const PREDICTION_SUCCESS_POINTS = 100;
export const PREDICTION_PERFECT_BONUS_POINTS = 50;

View File

@ -6,6 +6,7 @@ import { runChatToolsSheet, renderSheetMarkdown } from "../services/chatToolsShe
import { runChatProbe, runChatProbeBatch, runChatProbeConversation, renderProbeMarkdown } from "../services/chatProbeService";
import { sendError } from "../middleware/errors";
import { daysAgoKst, parseDateString } from "../types/dateString";
import { settleDailyReward } from "../services/rewardSettlementService";
/**
* . .
@ -29,6 +30,12 @@ export const debug = onRequest({ timeoutSeconds: 300 }, async (req, res) => {
const tail = segs.slice(-1)[0];
try {
if (tail === "settle-reward" && req.method === "POST") {
const { uid, date } = req.body ?? {};
if (typeof uid !== "string" || typeof date !== "string") { res.status(400).json({ error: "uid and date are required" }); return; }
res.status(200).json(await settleDailyReward(uid, parseDateString(date)));
return;
}
if (tail === "forceSync") {
const dateParam =
(typeof req.query.date === "string" && req.query.date) ||

View File

@ -3,7 +3,7 @@ import {
type Transaction,
} from "firebase-admin/firestore";
import { firestore } from "../firebase";
import type { AttendanceMonthDoc } from "../types/panit";
import type { AttendanceMonthDoc, AttendanceStateDoc } from "../types/panit";
const USERS = "users";
const ATTENDANCE = "attendance";
@ -28,3 +28,7 @@ export async function getMonthDocTx(
const snap = await tx.get(monthDocRef(uid, month));
return snap.exists ? (snap.data() as AttendanceMonthDoc) : null;
}
export function stateDocRef(uid: string): DocumentReference { return firestore.doc(`users/${uid}/attendance/state`); }
export async function getStateDoc(uid: string): Promise<AttendanceStateDoc | null> { const s = await stateDocRef(uid).get(); return s.exists ? s.data() as AttendanceStateDoc : null; }
export async function getStateDocTx(tx: Transaction, uid: string): Promise<AttendanceStateDoc | null> { const s = await tx.get(stateDocRef(uid)); return s.exists ? s.data() as AttendanceStateDoc : null; }

View File

@ -1,78 +1,12 @@
import {
FieldValue,
type DocumentReference,
type Transaction,
} from "firebase-admin/firestore";
import type { Transaction } from "firebase-admin/firestore";
import { firestore } from "../firebase";
import type { PointLedgerEntry, PointLedgerType } from "../types/panit";
import type { PointLedgerEntry } from "../types/points";
const USERS = "users";
const LEDGER = "pointLedger";
function ledgerCollection(uid: string) {
return firestore.collection(USERS).doc(uid).collection(LEDGER);
}
/**
* ledger row의 `balanceAfter`. 0.
*
* `createdAt desc, seq desc` row가
* `serverTimestamp()` `seq` .
*/
export async function getLatestBalance(uid: string): Promise<number> {
const snap = await ledgerCollection(uid)
.orderBy("createdAt", "desc")
.orderBy("seq", "desc")
.limit(1)
.get();
if (snap.empty) return 0;
return (snap.docs[0].data() as PointLedgerEntry).balanceAfter;
}
/**
* . `tx.get(query)` .
*/
export async function getLatestBalanceTx(
tx: Transaction,
uid: string
): Promise<number> {
const query = ledgerCollection(uid)
.orderBy("createdAt", "desc")
.orderBy("seq", "desc")
.limit(1);
const snap = await tx.get(query);
if (snap.empty) return 0;
return (snap.docs[0].data() as PointLedgerEntry).balanceAfter;
}
export interface NewLedgerEntry {
type: PointLedgerType;
amount: number;
balanceAfter: number;
seq: number;
refMonth?: string;
refDay?: number;
}
/**
* ledger row를 . autoId는
* row가 doc을 .
*/
export function createLedgerEntryTx(
tx: Transaction,
uid: string,
entry: NewLedgerEntry
): DocumentReference {
const ref = ledgerCollection(uid).doc();
const data: Record<string, unknown> = {
type: entry.type,
amount: entry.amount,
balanceAfter: entry.balanceAfter,
seq: entry.seq,
createdAt: FieldValue.serverTimestamp(),
};
if (entry.refMonth !== undefined) data.refMonth = entry.refMonth;
if (entry.refDay !== undefined) data.refDay = entry.refDay;
tx.create(ref, data);
return ref;
function col(uid: string) { return firestore.collection(`users/${uid}/pointLedger`); }
export function createLedgerEntryTx(tx: Transaction, uid: string, entry: PointLedgerEntry) { const ref = col(uid).doc(entry.txId); tx.create(ref, entry); return ref; }
export async function listLedger(uid: string, limit = 20, cursor?: string) {
let q = col(uid).orderBy("createdAt", "desc").limit(Math.min(Math.max(limit, 1), 100));
if (cursor) { const snap = await col(uid).doc(cursor).get(); if (snap.exists) q = q.startAfter(snap) as typeof q; }
const snap = await q.get();
return { items: snap.docs.map((d) => ({ id: d.id, ...(d.data() as PointLedgerEntry) })), cursor: snap.docs.length ? snap.docs[snap.docs.length - 1].id : null };
}

View File

@ -20,7 +20,6 @@ export interface RegisterInput {
favoriteTeamCode?: TeamCode;
knowledgeLevel: KnowledgeLevel;
}
/**
* .
*
@ -243,7 +242,6 @@ export interface DailyJudgmentResult {
* - perfect/success +1, ,
* - fail 0 ( )
* - skip ,
* - perfect `tickets.dailyAllKill` +1
*
* `computePoints` (streakAfter)
* . `fail|skip` .
@ -290,17 +288,12 @@ export async function applyDailyJudgmentTx(
const currentStreak = input.streakBrokenIn ? 0 : user.currentStreak ?? 0;
const highestStreak = user.highestStreak ?? 0;
const tierPoints = user.tierPoints ?? 0;
const dailyAllKill = user.tickets?.dailyAllKill ?? 0;
const weeklyMaster = user.tickets?.weeklyMaster ?? 0;
let nextStreak = currentStreak;
let nextPoints = tierPoints;
let nextDailyAllKill = dailyAllKill;
if (input.judgment === "perfect" || input.judgment === "success") {
nextStreak = currentStreak + 1;
nextPoints = tierPoints + input.computePoints(nextStreak);
if (input.judgment === "perfect") nextDailyAllKill = dailyAllKill + 1;
} else if (input.judgment === "fail") {
nextStreak = 0;
}
@ -310,10 +303,6 @@ export async function applyDailyJudgmentTx(
currentStreak: nextStreak,
highestStreak: Math.max(highestStreak, nextStreak),
tierPoints: nextPoints,
tickets: {
dailyAllKill: nextDailyAllKill,
weeklyMaster,
},
lastJudgedDate: date,
};
// 판정 전 rank 스냅샷을 같은 patch에 합쳐 user doc write를 1회로 줄인다.
@ -329,37 +318,3 @@ export async function applyDailyJudgmentTx(
};
});
}
/**
* . no-op.
*
* @returns `true`, `false`.
*/
export async function applyWeeklyMasterTx(
uid: string,
tuesday: DateString
): Promise<boolean> {
const ref = firestore.collection(COLLECTION).doc(uid);
return firestore.runTransaction(async (tx) => {
const snap = await tx.get(ref);
const user = (snap.data() ?? {}) as Partial<User>;
if (user.lastWeeklyMasterTuesday === tuesday) return false;
const dailyAllKill = user.tickets?.dailyAllKill ?? 0;
const weeklyMaster = user.tickets?.weeklyMaster ?? 0;
tx.set(
ref,
{
tickets: {
dailyAllKill,
weeklyMaster: weeklyMaster + 1,
},
lastWeeklyMasterTuesday: tuesday,
},
{ merge: true }
);
return true;
});
}

View File

@ -0,0 +1,8 @@
import type { Transaction } from "firebase-admin/firestore";
import { firestore } from "../firebase";
import type { WalletDoc } from "../types/points";
export function walletDocRef(uid: string) { return firestore.doc(`users/${uid}/wallet/current`); }
export async function getWallet(uid: string): Promise<WalletDoc | null> { const s = await walletDocRef(uid).get(); return s.exists ? s.data() as WalletDoc : null; }
export async function getWalletTx(tx: Transaction, uid: string): Promise<WalletDoc | null> { const s = await tx.get(walletDocRef(uid)); return s.exists ? s.data() as WalletDoc : null; }
export async function getAvailableBalance(uid: string): Promise<number> { return (await getWallet(uid))?.availableBalance ?? 0; }

View File

@ -3,7 +3,7 @@ import { logger } from "firebase-functions";
import { rtdb } from "../firebase";
import { setDay } from "../repositories/voteHistoryRepository";
import { invalidateStats } from "../services/statsService";
import { judgeDay, judgeWeekIfNeeded } from "../services/judgmentService";
import { judgeDay } from "../services/judgmentService";
import {
precomputeScoreboardCache,
computeRankSnapshot,
@ -13,7 +13,6 @@ import { getGame, createGameDayCache } from "../repositories/gameRepository";
import { processGameEndWithGame } from "../services/gameResultService";
import { DRAW_TEAM_CODE, type RankSnapshot, type VoteHistoryDoc } from "../types/panit";
import {
dayOfWeek,
daysAgoKst,
type DateString,
} from "../types/dateString";
@ -162,17 +161,6 @@ export async function runDailyArchive(
archived += 1;
}
// 일요일(dow=0) 아카이브/판정이 끝난 시점에 주간 마스터 티켓 지급 판단.
if (dayOfWeek(date) === 0) {
for (const uid of judgedUids) {
try {
await judgeWeekIfNeeded(uid, date);
} catch (err) {
logger.error(`judgeWeek failed uid=${uid} date=${date}`, err);
}
}
}
logger.info(`dailyArchive done: ${archived} users archived for ${date}`);
return { date, archived, judgedUids };
} finally {

View File

@ -0,0 +1,5 @@
import { firestore } from "../firebase";
import { HttpError } from "../middleware/errors";
import { PointLedgerType } from "../types/points";
import { applyPointChangesTx } from "./pointService";
export async function adminPointChange(uid: string, amount: number, kind: "credit" | "debit", clientIdempotencyKey: string, reason: string, actor: string, reversalOf?: string) { if (!/^[A-Za-z0-9_-]{1,100}$/.test(clientIdempotencyKey) || !Number.isSafeInteger(amount) || amount <= 0 || typeof reason !== "string" || reason.length < 1 || reason.length > 500) throw new HttpError(400, "invalid admin point change", "INVALID_INPUT"); return firestore.runTransaction((tx) => applyPointChangesTx(tx, uid, [{ txId: `${uid}:admin:${clientIdempotencyKey}`, type: kind === "credit" ? PointLedgerType.AdminCredit : PointLedgerType.AdminDebit, amount, adminReason: reason, adminActor: actor, reversalOf }])); }

View File

@ -1,274 +1,39 @@
import type { DecodedIdToken } from "firebase-admin/auth";
import { Timestamp } from "firebase-admin/firestore";
import { firestore } from "../firebase";
import { ATTENDANCE_DAILY_POINTS, ATTENDANCE_STREAK_10_INTERVAL_POINTS, ATTENDANCE_STREAK_5_POINTS } from "../constants/points";
import { HttpError } from "../middleware/errors";
import {
AttendanceResult,
PointLedgerType,
type AttendanceCheckInResult,
type AttendanceMonth,
type AttendanceMonthDoc,
type PointAward,
} from "../types/panit";
import {
dayOfWeek,
isMonthString,
lastDayOfMonth,
monthOf,
toDateString,
} from "../types/dateString";
import {
getMonthDoc,
getMonthDocTx,
monthDocRef,
} from "../repositories/attendanceRepository";
import {
createLedgerEntryTx,
getLatestBalance,
getLatestBalanceTx,
} from "../repositories/pointLedgerRepository";
import { getMonthDoc, getMonthDocTx, getStateDocTx, monthDocRef, stateDocRef } from "../repositories/attendanceRepository";
import { getAvailableBalance, getWalletTx } from "../repositories/walletRepository";
import { applyPointChangesTx } from "./pointService";
import { AttendanceResult, PointLedgerType, type AttendanceCheckInResult, type AttendanceMonth, type AttendanceStateDoc, type PointAward } from "../types/panit";
import { addDays, isMonthString, monthOf, toDateString } from "../types/dateString";
const SKEW_TOLERANCE_MS = 5 * 60 * 1000;
const DAILY_AMOUNT = 10;
const WEEKLY_BONUS_AMOUNT = 50;
const MONTHLY_BONUS_AMOUNT = 100;
export interface CheckInBody {
clientAttemptedAt?: unknown;
clientIdempotencyKey?: unknown;
}
// ISO 8601 끝부분의 timezone 표기. `Z`(UTC) 또는 `±HH:MM` / `±HHMM` 둘 다 허용.
// TZ 표기 누락 시 서버 파싱이 UTC로 폴백되어 +9h 어긋남이 발생하므로 강제한다.
const TZ_SUFFIX_PATTERN = /(Z|[+-]\d{2}:?\d{2})$/;
/**
* ISO 8601 Date로 .
* timezone (`Z` `±HH:MM`)
* instant로 .
*
* Flutter에서는 `DateTime.now().toUtc().toIso8601String()`
* ( `Z` ).
*/
function parseClientAttemptedAt(value: unknown): Date {
if (typeof value !== "string") {
throw new HttpError(
400,
"clientAttemptedAt must be ISO 8601 string",
"INVALID_INPUT"
);
}
if (!TZ_SUFFIX_PATTERN.test(value)) {
throw new HttpError(
400,
"clientAttemptedAt must include timezone (e.g. 'Z' or '+09:00')",
"INVALID_INPUT"
);
}
const ms = Date.parse(value);
if (Number.isNaN(ms)) {
throw new HttpError(
400,
"clientAttemptedAt is not a valid ISO 8601 date",
"INVALID_INPUT"
);
}
return new Date(ms);
}
function parseIdempotencyKey(value: unknown): string {
if (typeof value !== "string" || value.length === 0 || value.length > 128) {
throw new HttpError(
400,
"clientIdempotencyKey must be a non-empty string ≤128 chars",
"INVALID_INPUT"
);
}
return value;
}
/**
* . ISO ~ 6 .
* (Sunday ) .
*/
function isPriorSixDaysAttended(days: number[], sundayDay: number): boolean {
if (sundayDay < 7) return false; // 그 달 첫 주: 월~토가 이전 달이라 불가능
const set = new Set(days);
for (let d = sundayDay - 6; d < sundayDay; d++) {
if (!set.has(d)) return false;
}
return true;
}
function sortedInsert(days: number[], day: number): number[] {
const next = [...days, day];
next.sort((a, b) => a - b);
return next;
}
export async function checkIn(
token: DecodedIdToken,
body: CheckInBody
): Promise<AttendanceCheckInResult> {
const clientAttemptedAt = parseClientAttemptedAt(body.clientAttemptedAt);
const idempotencyKey = parseIdempotencyKey(body.clientIdempotencyKey);
const serverNowDate = new Date();
const skewMs = clientAttemptedAt.getTime() - serverNowDate.getTime();
if (Math.abs(skewMs) > SKEW_TOLERANCE_MS) {
throw new HttpError(
409,
"client clock differs from server by more than 5 minutes",
"CLOCK_SKEW",
{
serverNow: Timestamp.fromDate(serverNowDate),
clientAttemptedAt: Timestamp.fromDate(clientAttemptedAt),
skewMs,
}
);
}
const today = toDateString(serverNowDate);
const month = monthOf(today);
const todayDay = Number(today.slice(8, 10));
const monthRef = monthDocRef(token.uid, month);
const SKEW = 5 * 60 * 1000; const TZ = /(Z|[+-]\d{2}:?\d{2})$/;
export interface CheckInBody { clientAttemptedAt?: unknown; clientIdempotencyKey?: unknown }
function attempted(v: unknown) { if (typeof v !== "string" || !TZ.test(v) || Number.isNaN(Date.parse(v))) throw new HttpError(400, "invalid clientAttemptedAt", "INVALID_INPUT"); return new Date(v); }
function key(v: unknown) { if (typeof v !== "string" || v.length < 1 || v.length > 128) throw new HttpError(400, "invalid clientIdempotencyKey", "INVALID_INPUT"); return v; }
function insert(days: number[], day: number) { return [...days, day].sort((a, b) => a - b); }
export async function checkIn(token: DecodedIdToken, body: CheckInBody): Promise<AttendanceCheckInResult> {
const clientAt = attempted(body.clientAttemptedAt); const idem = key(body.clientIdempotencyKey); const nowDate = new Date();
if (Math.abs(clientAt.getTime() - nowDate.getTime()) > SKEW) throw new HttpError(409, "client clock differs from server by more than 5 minutes", "CLOCK_SKEW", { serverNow: Timestamp.fromDate(nowDate) });
const today = toDateString(nowDate); const month = monthOf(today); const day = Number(today.slice(8));
return firestore.runTransaction(async (tx) => {
// ── 읽기 단계 (모든 read는 write 전에) ──
const monthDoc = await getMonthDocTx(tx, token.uid, month);
const currentBalance = await getLatestBalanceTx(tx, token.uid);
// ── 멱등 가드: 같은 idempotencyKey 재호출 → 직전 결과 그대로 ──
if (
monthDoc?.lastIdempotencyKey === idempotencyKey &&
monthDoc.lastResult
) {
return monthDoc.lastResult;
}
const monthDoc = await getMonthDocTx(tx, token.uid, month); const state = await getStateDocTx(tx, token.uid);
if (monthDoc?.lastIdempotencyKey === idem && monthDoc.lastResult) return monthDoc.lastResult;
const days = monthDoc?.days ?? [];
// ── 이미 출석 가드: 같은 날 다른 키 → alreadyCheckedIn ──
if (days.includes(todayDay)) {
const result: AttendanceCheckInResult = {
result: AttendanceResult.AlreadyCheckedIn,
serverNow: Timestamp.fromDate(serverNowDate),
attendedDays: days,
totalCount: days.length,
pointsAwarded: [],
balanceAfter: currentBalance,
};
tx.set(
monthRef,
{
lastIdempotencyKey: idempotencyKey,
lastResult: result,
},
{ merge: true }
);
return result;
}
// ── 신규 출석 처리 ──
const newDays = sortedInsert(days, todayDay);
const awards: PointAward[] = [];
let runningBalance = currentBalance;
let seq = 0;
// 1) daily +10
runningBalance += DAILY_AMOUNT;
awards.push({ type: PointLedgerType.AttendanceDaily, amount: DAILY_AMOUNT });
createLedgerEntryTx(tx, token.uid, {
type: PointLedgerType.AttendanceDaily,
amount: DAILY_AMOUNT,
balanceAfter: runningBalance,
seq: seq++,
refMonth: month,
refDay: todayDay,
});
// 2) weekly_bonus: 출석일이 일요일 + 그 주 월~토 모두 출석
if (
dayOfWeek(today) === 0 &&
isPriorSixDaysAttended(newDays, todayDay)
) {
runningBalance += WEEKLY_BONUS_AMOUNT;
awards.push({
type: PointLedgerType.AttendanceWeeklyBonus,
amount: WEEKLY_BONUS_AMOUNT,
});
createLedgerEntryTx(tx, token.uid, {
type: PointLedgerType.AttendanceWeeklyBonus,
amount: WEEKLY_BONUS_AMOUNT,
balanceAfter: runningBalance,
seq: seq++,
refMonth: month,
refDay: todayDay,
});
}
// 3) monthly_bonus: 그 달 1~말일 모두 출석
if (newDays.length === lastDayOfMonth(month)) {
runningBalance += MONTHLY_BONUS_AMOUNT;
awards.push({
type: PointLedgerType.AttendanceMonthlyBonus,
amount: MONTHLY_BONUS_AMOUNT,
});
createLedgerEntryTx(tx, token.uid, {
type: PointLedgerType.AttendanceMonthlyBonus,
amount: MONTHLY_BONUS_AMOUNT,
balanceAfter: runningBalance,
seq: seq++,
refMonth: month,
refDay: todayDay,
});
}
const result: AttendanceCheckInResult = {
result: AttendanceResult.CheckedIn,
serverNow: Timestamp.fromDate(serverNowDate),
attendedDays: newDays,
totalCount: newDays.length,
pointsAwarded: awards,
balanceAfter: runningBalance,
};
const monthPatch: Partial<AttendanceMonthDoc> = {
days: newDays,
lastCheckedInAt: Timestamp.fromDate(serverNowDate),
lastIdempotencyKey: idempotencyKey,
lastResult: result,
};
tx.set(monthRef, monthPatch, { merge: true });
return result;
if (days.includes(day)) { const wallet = await getWalletTx(tx, token.uid); return { result: AttendanceResult.AlreadyCheckedIn, serverNow: Timestamp.fromDate(nowDate), attendedDays: days, totalCount: days.length, pointsAwarded: [], balanceAfter: wallet?.availableBalance ?? 0, attendanceStreak: state?.currentAttendanceStreak ?? 0 }; }
const consecutive = state && addDays(state.lastAttendanceDate, 1) === today;
const streak = consecutive ? state.currentAttendanceStreak + 1 : 1; const cycle = consecutive ? state.streakCycleStart : today;
const awards: PointAward[] = [{ type: PointLedgerType.AttendanceDaily, amount: ATTENDANCE_DAILY_POINTS }];
const changes = [{ txId: `${token.uid}:${today}:attendance_daily`, type: PointLedgerType.AttendanceDaily, amount: ATTENDANCE_DAILY_POINTS, relatedDate: today }];
if (streak === 5) { awards.push({ type: PointLedgerType.AttendanceStreak5, amount: ATTENDANCE_STREAK_5_POINTS }); changes.push({ txId: `${token.uid}:${cycle}:attendance_streak_5`, type: PointLedgerType.AttendanceStreak5, amount: ATTENDANCE_STREAK_5_POINTS, relatedDate: today }); }
if (streak % 10 === 0) { awards.push({ type: PointLedgerType.AttendanceStreak10Interval, amount: ATTENDANCE_STREAK_10_INTERVAL_POINTS }); changes.push({ txId: `${token.uid}:${cycle}:${streak}:attendance_streak_10_interval`, type: PointLedgerType.AttendanceStreak10Interval, amount: ATTENDANCE_STREAK_10_INTERVAL_POINTS, relatedDate: today }); }
const wallet = await applyPointChangesTx(tx, token.uid, changes); const newDays = insert(days, day);
const result: AttendanceCheckInResult = { result: AttendanceResult.CheckedIn, serverNow: Timestamp.fromDate(nowDate), attendedDays: newDays, totalCount: newDays.length, pointsAwarded: awards, balanceAfter: wallet!.availableBalance, attendanceStreak: streak };
const next: AttendanceStateDoc = { lastAttendanceDate: today, currentAttendanceStreak: streak, streakCycleStart: cycle, highestAttendanceStreak: Math.max(state?.highestAttendanceStreak ?? 0, streak), updatedAt: Timestamp.fromDate(nowDate) };
tx.set(stateDocRef(token.uid), next); tx.set(monthDocRef(token.uid, month), { days: newDays, lastCheckedInAt: Timestamp.fromDate(nowDate), lastIdempotencyKey: idem, lastResult: result }, { merge: true }); return result;
});
}
export async function getMonth(
token: DecodedIdToken,
monthRaw: unknown
): Promise<AttendanceMonth> {
if (!isMonthString(monthRaw)) {
throw new HttpError(
400,
"month must be YYYY-MM",
"INVALID_INPUT"
);
}
const month = monthRaw;
const [doc, balance] = await Promise.all([
getMonthDoc(token.uid, month),
getLatestBalance(token.uid),
]);
if (!doc) {
throw new HttpError(404, "month not found", "MONTH_NOT_FOUND");
}
return {
month,
attendedDays: doc.days,
totalCount: doc.days.length,
balance,
};
}
export async function getMonth(token: DecodedIdToken, raw: unknown): Promise<AttendanceMonth> { if (!isMonthString(raw)) throw new HttpError(400, "month must be YYYY-MM", "INVALID_INPUT"); const [doc, balance] = await Promise.all([getMonthDoc(token.uid, raw), getAvailableBalance(token.uid)]); if (!doc) throw new HttpError(404, "month not found", "MONTH_NOT_FOUND"); return { month: raw, attendedDays: doc.days, totalCount: doc.days.length, balance }; }

View File

@ -5,7 +5,7 @@ import { getRank } from "./rankService";
import { resolveTeamCode } from "./chatContextService";
import { getDay } from "../repositories/voteHistoryRepository";
import { getMonthDoc } from "../repositories/attendanceRepository";
import { getLatestBalance } from "../repositories/pointLedgerRepository";
import { getAvailableBalance } from "../repositories/walletRepository";
import { TEAM_DISPLAY_NAMES, KBO_RANK_TEAM_NAMES } from "../constants/chatPrompts";
import type { ChatTool } from "./chatProviderService";
import type { ChatToolCallInfo } from "../types/chat";
@ -535,7 +535,7 @@ export function buildChatTools(ctx: ChatToolContext): ChatTool[] {
if (!month) return "월은 \"YYYY-MM\"이나 \"지난달\" 같은 표현으로 알려줘.";
const [doc, balance] = await Promise.all([
getMonthDoc(ctx.uid, month),
getLatestBalance(ctx.uid),
getAvailableBalance(ctx.uid),
]);
return formatAttendanceResult(month, doc, balance);
} catch (err) {

View File

@ -3,10 +3,9 @@ import {
createGameDayCache,
type GameDayCache,
} from "../repositories/gameRepository";
import { getDay, getRange, setDay } from "../repositories/voteHistoryRepository";
import { getDay, setDay } from "../repositories/voteHistoryRepository";
import {
applyDailyJudgmentTx,
applyWeeklyMasterTx,
getUser,
} from "../repositories/userRepository";
import {
@ -15,7 +14,8 @@ import {
thresholdsFor,
} from "../constants/judgment";
import type { DailyJudgment, RankSnapshot, VoteHistoryDoc } from "../types/panit";
import { addDays, tuesdayOf, type DateString } from "../types/dateString";
import { addDays, type DateString } from "../types/dateString";
import { settleDailyReward } from "./rewardSettlementService";
/**
* `(lastJudged, upTo)` ( ) ** **(=`thresholdsFor`
@ -103,6 +103,7 @@ export async function judgeDay(
// 정상 멱등 재실행에서는 doc이 이미 존재하므로 판정 필드를 덮어쓰지 않는다.
const existing = await getDay(uid, date);
if (!existing) await setDay(uid, date, voteDoc);
await settleDailyReward(uid, date).catch((err) => logger.error(`reward settlement failed uid=${uid} date=${date}`, err));
return;
}
@ -113,6 +114,7 @@ export async function judgeDay(
completedCount,
streakAfter: tx.streakAfter,
});
await settleDailyReward(uid, date).catch((err) => logger.error(`reward settlement failed uid=${uid} date=${date}`, err));
}
/**
@ -122,24 +124,3 @@ export async function judgeDay(
* @param uid - ID
* @param sundayDate - /
*/
export async function judgeWeekIfNeeded(
uid: string,
sundayDate: DateString
): Promise<void> {
const tuesday = tuesdayOf(sundayDate);
const entries = await getRange(uid, tuesday, sundayDate);
if (entries.length === 0) return;
const byDate = new Map(entries.map((e) => [e.date, e.doc] as const));
for (let i = 0; i < 6; i++) {
const d = addDays(tuesday, i);
const doc = byDate.get(d);
if (!doc || !doc.judgment) return; // 판정 누락
if (doc.judgment === "fail") return;
}
const granted = await applyWeeklyMasterTx(uid, tuesday);
if (granted) {
logger.info(`weekly-master granted: uid=${uid} tuesday=${tuesday}`);
}
}

View File

@ -0,0 +1,36 @@
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)); }

View File

@ -0,0 +1,33 @@
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 };
}
}

View File

@ -208,7 +208,6 @@ async function computeStats(uid: string, period: Period): Promise<StatsResponse>
const streakDays = storedStreak ?? computeStreak(all);
const highestStreak = user?.highestStreak ?? streakDays;
const tierPoints = user?.tierPoints ?? 0;
const tickets = user?.tickets ?? {dailyAllKill: 0, weeklyMaster: 0};
const weeklyResults = weeklyResultsOf(all);
const {level, progress} = computeLevel(periodAgg.total);
@ -229,7 +228,6 @@ async function computeStats(uid: string, period: Period): Promise<StatsResponse>
progress,
tier: tierOf(tierPoints),
tierPoints,
tickets,
updatedAt: Date.now(),
forDate: today,
};

View File

@ -36,16 +36,8 @@ export enum KnowledgeLevel {
Expert = "expert",
}
export interface TicketMap {
dailyAllKill: number;
weeklyMaster: number;
}
export enum PointLedgerType {
AttendanceDaily = "attendance_daily",
AttendanceWeeklyBonus = "attendance_weekly_bonus",
AttendanceMonthlyBonus = "attendance_monthly_bonus",
}
export { PointLedgerType } from "./points";
import type { PointLedgerType } from "./points";
export enum AttendanceResult {
CheckedIn = "checkedIn",
@ -63,17 +55,6 @@ export type NotificationsMap = Partial<Record<NotificationKey, boolean>>;
* `balanceAfter` row의 `balanceAfter`.
* row가 `seq` .
*/
export interface PointLedgerEntry {
type: PointLedgerType;
amount: number;
balanceAfter: number;
refMonth?: string;
refDay?: number;
createdAt: Timestamp;
/** 같은 트랜잭션 내 동시 작성 시 안정 정렬용. 0,1,2,... */
seq: number;
}
export interface AttendanceMonthDoc {
/** 정렬된 1~말일 배열. */
days: number[];
@ -82,6 +63,14 @@ export interface AttendanceMonthDoc {
lastResult?: AttendanceCheckInResult;
}
export interface AttendanceStateDoc {
lastAttendanceDate: DateString;
currentAttendanceStreak: number;
streakCycleStart: DateString;
highestAttendanceStreak: number;
updatedAt: Timestamp;
}
export interface PointAward {
type: PointLedgerType;
amount: number;
@ -94,6 +83,7 @@ export interface AttendanceCheckInResult {
totalCount: number;
pointsAwarded: PointAward[];
balanceAfter: number;
attendanceStreak: number;
}
export interface AttendanceMonth {
@ -148,7 +138,6 @@ export interface User {
* , .
*/
tierPoints?: number;
tickets?: TicketMap;
/**
* KST .
@ -157,7 +146,6 @@ export interface User {
*/
lastJudgedDate?: DateString;
lastWeeklyMasterTuesday?: DateString;
rankSnapshot?: RankSnapshot;
}
@ -217,6 +205,8 @@ export interface VoteHistoryDoc {
correctCount?: number;
completedCount?: number;
streakAfter?: number;
rewardSettledAt?: Timestamp;
rewardTotal?: number;
}
export interface StatsResponse {
@ -236,7 +226,6 @@ export interface StatsResponse {
progress: number;
tier: TierName;
tierPoints: number;
tickets: TicketMap;
updatedAt: number;
forDate: DateString;
}

68
src/types/points.ts Normal file
View File

@ -0,0 +1,68 @@
import type { Timestamp } from "firebase-admin/firestore";
export enum PointLedgerType {
AttendanceDaily = "attendance_daily",
AttendanceStreak5 = "attendance_streak_5",
AttendanceStreak10Interval = "attendance_streak_10_interval",
PredictionDailyParticipation = "prediction_daily_participation",
PredictionDailySuccess = "prediction_daily_success",
PredictionDailyPerfect = "prediction_daily_perfect",
EventCredit = "event_credit",
AdminCredit = "admin_credit",
AdminDebit = "admin_debit",
OrderReserve = "order_reserve",
OrderCapture = "order_capture",
OrderRelease = "order_release",
OrderRefund = "order_refund",
PointExpiry = "point_expiry",
}
export type PointOperation = "credit" | "debit" | "reserve" | "capture" | "release";
export const OP_BY_TYPE: Record<PointLedgerType, PointOperation> = {
[PointLedgerType.AttendanceDaily]: "credit",
[PointLedgerType.AttendanceStreak5]: "credit",
[PointLedgerType.AttendanceStreak10Interval]: "credit",
[PointLedgerType.PredictionDailyParticipation]: "credit",
[PointLedgerType.PredictionDailySuccess]: "credit",
[PointLedgerType.PredictionDailyPerfect]: "credit",
[PointLedgerType.EventCredit]: "credit",
[PointLedgerType.AdminCredit]: "credit",
[PointLedgerType.AdminDebit]: "debit",
[PointLedgerType.OrderReserve]: "reserve",
[PointLedgerType.OrderCapture]: "capture",
[PointLedgerType.OrderRelease]: "release",
[PointLedgerType.OrderRefund]: "credit",
[PointLedgerType.PointExpiry]: "debit",
};
export interface WalletDoc {
availableBalance: number;
reservedBalance: number;
totalEarned: number;
totalSpent: number;
version: number;
createdAt: Timestamp;
updatedAt: Timestamp;
}
export interface PointChange {
txId: string;
type: PointLedgerType;
amount: number;
relatedDate?: string;
orderId?: string;
reversalOf?: string;
adminReason?: string;
adminActor?: string;
}
export interface PointLedgerEntry extends PointChange {
uid: string;
op: PointOperation;
availableBefore: number;
availableAfter: number;
reservedBefore: number;
reservedAfter: number;
createdAt: Timestamp;
}

View File

@ -2,323 +2,40 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { DecodedIdToken } from "firebase-admin/auth";
import { firestore } from "../../src/firebase";
import { checkIn, getMonth } from "../../src/services/attendanceService";
import { updateNotifications } from "../../src/services/userService";
import {
AttendanceResult,
PointLedgerType,
type AttendanceMonthDoc,
type PointLedgerEntry,
} from "../../src/types/panit";
import { AttendanceResult, PointLedgerType } from "../../src/types/panit";
import type { PointLedgerEntry, WalletDoc } from "../../src/types/points";
const uid = "att-user-1";
const token = { uid, firebase: { identities: {}, sign_in_provider: "google.com" } } as unknown as DecodedIdToken;
function at(iso: string, key: string) { const d = new Date(iso); vi.setSystemTime(d); return { clientAttemptedAt: d.toISOString(), clientIdempotencyKey: key }; }
async function ledger() { const s = await firestore.collection(`users/${uid}/pointLedger`).get(); return s.docs.map((d) => d.data() as PointLedgerEntry); }
async function wallet() { return (await firestore.doc(`users/${uid}/wallet/current`).get()).data() as WalletDoc; }
function fakeToken(): DecodedIdToken {
return {
uid,
email: "att@example.com",
firebase: { identities: {}, sign_in_provider: "google.com" },
aud: "test",
auth_time: 0,
exp: 0,
iat: 0,
iss: "test",
sub: uid,
} as DecodedIdToken;
}
describe("attendanceService reward wallet", () => {
beforeEach(async () => { await firestore.recursiveDelete(firestore.doc(`users/${uid}`)); vi.useFakeTimers({ toFake: ["Date"] }); });
afterEach(() => vi.useRealTimers());
/** 주어진 KST 날짜+시각의 절대 Date. */
function kstDate(iso: string): Date {
return new Date(iso);
}
/**
* KST valid한 client body를 .
* clientAttemptedAt은 UTC ISO 8601 (`Z` ) Flutter의
* `DateTime.now().toUtc().toIso8601String()` .
*/
function bodyAt(now: Date, key = "k1") {
return {
clientAttemptedAt: now.toISOString(), // 항상 'Z' 접미사 포함
clientIdempotencyKey: key,
};
}
async function readLedger(): Promise<PointLedgerEntry[]> {
const snap = await firestore
.collection("users")
.doc(uid)
.collection("pointLedger")
.orderBy("createdAt", "asc")
.orderBy("seq", "asc")
.get();
return snap.docs.map((d) => d.data() as PointLedgerEntry);
}
async function readMonthDoc(month: string): Promise<AttendanceMonthDoc | null> {
const snap = await firestore
.collection("users")
.doc(uid)
.collection("attendance")
.doc(month)
.get();
return snap.exists ? (snap.data() as AttendanceMonthDoc) : null;
}
describe("attendanceService", () => {
beforeEach(async () => {
await firestore.recursiveDelete(firestore.collection("users").doc(uid));
vi.useFakeTimers({ toFake: ["Date"] });
it("첫 출석은 20P를 지급하고 같은 키 replay는 원장을 늘리지 않는다", async () => {
const body = at("2026-05-12T14:23:11+09:00", "one"); const first = await checkIn(token, body); const replay = await checkIn(token, body);
expect(first).toMatchObject({ result: AttendanceResult.CheckedIn, balanceAfter: 20, attendanceStreak: 1, pointsAwarded: [{ type: PointLedgerType.AttendanceDaily, amount: 20 }] });
expect(replay).toEqual(first); expect(await ledger()).toHaveLength(1); expect((await wallet()).availableBalance).toBe(20);
});
afterEach(() => {
vi.useRealTimers();
it("5일차 70P, 10일차 120P 스트릭 보너스를 지급한다", async () => {
let fifth; let tenth;
for (let day = 1; day <= 10; day++) { const result = await checkIn(token, at(`2026-05-${String(day).padStart(2, "0")}T12:00:00+09:00`, `k${day}`)); if (day === 5) fifth = result; if (day === 10) tenth = result; }
expect(fifth).toMatchObject({ balanceAfter: 150, attendanceStreak: 5 });
expect(tenth).toMatchObject({ balanceAfter: 350, attendanceStreak: 10 });
expect((await ledger()).filter((x) => x.type === PointLedgerType.AttendanceStreak5)).toHaveLength(1);
expect((await ledger()).filter((x) => x.type === PointLedgerType.AttendanceStreak10Interval)).toHaveLength(1);
});
describe("checkIn", () => {
it("첫 출석 → checkedIn, daily +10, ledger 1건", async () => {
const now = kstDate("2026-05-12T14:23:11+09:00");
vi.setSystemTime(now);
const result = await checkIn(fakeToken(), bodyAt(now));
expect(result.result).toBe(AttendanceResult.CheckedIn);
expect(result.attendedDays).toEqual([12]);
expect(result.totalCount).toBe(1);
expect(result.balanceAfter).toBe(10);
expect(result.pointsAwarded).toEqual([
{ type: PointLedgerType.AttendanceDaily, amount: 10 },
]);
const ledger = await readLedger();
expect(ledger).toHaveLength(1);
expect(ledger[0].balanceAfter).toBe(10);
expect(ledger[0].refMonth).toBe("2026-05");
expect(ledger[0].refDay).toBe(12);
const month = await readMonthDoc("2026-05");
expect(month?.days).toEqual([12]);
expect(month?.lastIdempotencyKey).toBe("k1");
});
it("같은 idempotencyKey 재호출 → 동일 결과, ledger 추가 X", async () => {
const now = kstDate("2026-05-12T14:23:11+09:00");
vi.setSystemTime(now);
const r1 = await checkIn(fakeToken(), bodyAt(now, "k1"));
const r2 = await checkIn(fakeToken(), bodyAt(now, "k1"));
expect(r2).toEqual(r1);
expect(await readLedger()).toHaveLength(1);
});
it("같은 날 다른 idempotencyKey → alreadyCheckedIn, 보상 없음", async () => {
const now = kstDate("2026-05-12T14:23:11+09:00");
vi.setSystemTime(now);
await checkIn(fakeToken(), bodyAt(now, "k1"));
const r2 = await checkIn(fakeToken(), bodyAt(now, "k2"));
expect(r2.result).toBe(AttendanceResult.AlreadyCheckedIn);
expect(r2.pointsAwarded).toEqual([]);
expect(r2.balanceAfter).toBe(10);
expect(r2.attendedDays).toEqual([12]);
expect(await readLedger()).toHaveLength(1);
});
it("TZ 표기 없는 ISO 문자열 → 400 INVALID_INPUT", async () => {
const now = kstDate("2026-05-12T14:23:11+09:00");
vi.setSystemTime(now);
await expect(
checkIn(fakeToken(), {
clientAttemptedAt: "2026-05-12T14:23:11", // TZ 없음
clientIdempotencyKey: "k1",
})
).rejects.toMatchObject({ status: 400, code: "INVALID_INPUT" });
});
it("clientAttemptedAt이 서버보다 6분 미래면 409 CLOCK_SKEW + 시각 details", async () => {
const now = kstDate("2026-05-12T14:23:11+09:00");
vi.setSystemTime(now);
const future = new Date(now.getTime() + 6 * 60 * 1000);
await expect(
checkIn(fakeToken(), bodyAt(future, "k1"))
).rejects.toMatchObject({
status: 409,
code: "CLOCK_SKEW",
details: {
skewMs: 6 * 60 * 1000,
},
});
});
it("월~토 6일 출석 후 일요일 → daily + weekly_bonus, ledger 2건", async () => {
// 2026-05-04(월) ~ 2026-05-09(토) 출석 후 2026-05-10(일).
for (let day = 4; day <= 9; day++) {
const t = kstDate(
`2026-05-${String(day).padStart(2, "0")}T10:00:00+09:00`
);
vi.setSystemTime(t);
await checkIn(fakeToken(), bodyAt(t, `seed-${day}`));
}
const sunday = kstDate("2026-05-10T10:00:00+09:00");
vi.setSystemTime(sunday);
const result = await checkIn(fakeToken(), bodyAt(sunday, "sun"));
expect(result.pointsAwarded).toEqual([
{ type: PointLedgerType.AttendanceDaily, amount: 10 },
{ type: PointLedgerType.AttendanceWeeklyBonus, amount: 50 },
]);
expect(result.balanceAfter).toBe(7 * 10 + 50);
const ledger = await readLedger();
expect(ledger).toHaveLength(8); // 6 daily + (daily + weekly) on Sunday
expect(ledger[ledger.length - 1].type).toBe(
PointLedgerType.AttendanceWeeklyBonus
);
expect(ledger[ledger.length - 1].balanceAfter).toBe(120);
});
it("주가 월 경계를 걸치면 weekly_bonus 미발급 (단순화 정책)", async () => {
// 2026-06-01(월) ~ 2026-06-06(토) 출석 후 2026-06-07(일).
// 6월에 월~토 6일 + 일요일 7일째지만, 정책상 OK이므로 weekly 발급.
// 반례 테스트: 2026-05-25(월)~2026-05-31(일) 한 주는 같은 달 안에 들어가므로 발급.
// 더 명확한 반례: 2026-08-31이 월요일이라면 그 주 일요일은 9월 6일.
// 2026-08-31은 실제로 월요일임.
for (let day = 31; day <= 31; day++) {
const t = kstDate(`2026-08-${day}T10:00:00+09:00`);
vi.setSystemTime(t);
await checkIn(fakeToken(), bodyAt(t, `seed-aug-${day}`));
}
for (let day = 1; day <= 5; day++) {
const t = kstDate(
`2026-09-${String(day).padStart(2, "0")}T10:00:00+09:00`
);
vi.setSystemTime(t);
await checkIn(fakeToken(), bodyAt(t, `seed-sep-${day}`));
}
// 일요일 9/6
const sunday = kstDate("2026-09-06T10:00:00+09:00");
vi.setSystemTime(sunday);
const result = await checkIn(fakeToken(), bodyAt(sunday, "sun-cross"));
// weekly_bonus는 9월 days 안에 1~5만 있으므로 (8/31 월은 9월 doc에 없음)
// isWeekFullyAttended 통과 못 함.
expect(
result.pointsAwarded.find(
(a) => a.type === PointLedgerType.AttendanceWeeklyBonus
)
).toBeUndefined();
});
it("그 달 1~말일 모두 출석 → monthly_bonus 추가", async () => {
// 2026-02 (28일). 2/1 ~ 2/27까지 출석한 뒤 2/28에 트리거.
for (let day = 1; day <= 27; day++) {
const t = kstDate(
`2026-02-${String(day).padStart(2, "0")}T10:00:00+09:00`
);
vi.setSystemTime(t);
await checkIn(fakeToken(), bodyAt(t, `feb-${day}`));
}
const lastDay = kstDate("2026-02-28T10:00:00+09:00");
vi.setSystemTime(lastDay);
const result = await checkIn(fakeToken(), bodyAt(lastDay, "feb-28"));
const monthly = result.pointsAwarded.find(
(a) => a.type === PointLedgerType.AttendanceMonthlyBonus
);
expect(monthly).toEqual({
type: PointLedgerType.AttendanceMonthlyBonus,
amount: 100,
});
// 28 daily + (weekly 발급되는 주 수만큼) + 100 monthly.
// 2026-02 weekly 발급되는 일요일: 2/1, 2/8, 2/15, 2/22 (모두 같은 달, 직전 6일이 1월에 걸쳐있는 2/1만 별도).
// 그러나 2/1은 일요일인데 1/26~1/31이 같은 달이 아니라 weekly 미발급.
// 2/8 발급 (2~7 모두 같은 달), 2/15, 2/22 발급. 2/28(토)는 일요일 아님.
// weekly 3건 × 50 = 150. daily 28 × 10 = 280. monthly 100. 총 530.
expect(result.balanceAfter).toBe(530);
});
it("결석 후 스트릭과 cycleStart를 재시작한다", async () => {
await checkIn(token, at("2026-05-01T12:00:00+09:00", "a")); const result = await checkIn(token, at("2026-05-03T12:00:00+09:00", "b"));
expect(result.attendanceStreak).toBe(1); expect((await firestore.doc(`users/${uid}/attendance/state`).get()).data()?.streakCycleStart).toBe("2026-05-03");
});
describe("getMonth", () => {
it("출석 기록 없는 월 → 404 MONTH_NOT_FOUND", async () => {
await expect(getMonth(fakeToken(), "2026-05")).rejects.toMatchObject({
status: 404,
code: "MONTH_NOT_FOUND",
});
});
it("형식 오류 → 400 INVALID_INPUT", async () => {
await expect(getMonth(fakeToken(), "2026-5")).rejects.toMatchObject({
status: 400,
code: "INVALID_INPUT",
});
});
it("정상 월 조회 → days, totalCount, balance 반환", async () => {
const t = kstDate("2026-05-12T10:00:00+09:00");
vi.setSystemTime(t);
await checkIn(fakeToken(), bodyAt(t));
vi.useRealTimers();
const m = await getMonth(fakeToken(), "2026-05");
expect(m.month).toBe("2026-05");
expect(m.attendedDays).toEqual([12]);
expect(m.totalCount).toBe(1);
expect(m.balance).toBe(10);
});
});
});
describe("userService.updateNotifications", () => {
beforeEach(async () => {
await firestore.recursiveDelete(firestore.collection("users").doc(uid));
await firestore.collection("users").doc(uid).set({
displayName: "tester",
email: "x@x.com",
provider: "google",
knowledgeLevel: "casual",
});
});
it("attendance:true 토글 → merge 응답", async () => {
const result = await updateNotifications(fakeToken(), {
notifications: { attendance: true },
});
expect(result).toEqual({ attendance: true });
const user = await firestore.collection("users").doc(uid).get();
expect(user.data()?.notifications).toEqual({ attendance: true });
});
it("whitelist 외 키 → 400", async () => {
await expect(
updateNotifications(fakeToken(), {
notifications: { unknown: true } as never,
})
).rejects.toMatchObject({ status: 400, code: "INVALID_INPUT" });
});
it("boolean 아닌 값 → 400", async () => {
await expect(
updateNotifications(fakeToken(), {
notifications: { attendance: "yes" } as never,
})
).rejects.toMatchObject({ status: 400, code: "INVALID_INPUT" });
});
it("notifications 자체가 없으면 → 400", async () => {
await expect(
updateNotifications(fakeToken(), {} as never)
).rejects.toMatchObject({ status: 400, code: "INVALID_INPUT" });
});
it("미존재 유저 → 404", async () => {
await firestore.collection("users").doc(uid).delete();
await expect(
updateNotifications(fakeToken(), { notifications: { attendance: true } })
).rejects.toMatchObject({ status: 404, code: "USER_NOT_FOUND" });
it("월 조회 잔액은 지갑에서 반환한다", async () => {
await checkIn(token, at("2026-05-12T12:00:00+09:00", "m")); expect(await getMonth(token, "2026-05")).toMatchObject({ attendedDays: [12], totalCount: 1, balance: 20 });
});
});

View File

@ -3,7 +3,6 @@ import { Timestamp } from "firebase-admin/firestore";
import { firestore } from "../../src/firebase";
import {
judgeDay,
judgeWeekIfNeeded,
} from "../../src/services/judgmentService";
import { setDay } from "../../src/repositories/voteHistoryRepository";
import type { DateString } from "../../src/types/dateString";
@ -136,8 +135,6 @@ describe("judgmentService (Firestore emulator)", () => {
expect(hist.streakAfter).toBe(1);
expect(user.currentStreak).toBe(1);
expect(user.highestStreak).toBe(1);
expect(user.tickets?.dailyAllKill).toBe(1);
expect(user.tickets?.weeklyMaster).toBe(0);
expect(user.tierPoints).toBe(50); // 5 * 10, streak 1이라 보너스 0
expect(user.lastJudgedDate).toBe(TUE);
});
@ -160,7 +157,6 @@ describe("judgmentService (Firestore emulator)", () => {
expect(hist.judgment).toBe("success");
expect(user.currentStreak).toBe(1);
expect(user.tickets?.dailyAllKill).toBe(0);
expect(user.tierPoints).toBe(30);
});
@ -218,7 +214,6 @@ describe("judgmentService (Firestore emulator)", () => {
const user = await readUser();
const hist = await readVoteHistory(TUE);
expect(hist.judgment).toBe("perfect");
expect(user.tickets?.dailyAllKill).toBe(1);
});
it("4 completed, 2적중이면 success (success=2, perfect=4)", async () => {
@ -258,7 +253,6 @@ describe("judgmentService (Firestore emulator)", () => {
expect(hist.streakAfter).toBe(4);
expect(user.currentStreak).toBe(4);
expect(user.tierPoints).toBe(80);
expect(user.tickets?.dailyAllKill).toBe(0);
});
});
@ -388,84 +382,8 @@ describe("judgmentService (Firestore emulator)", () => {
const user = await readUser();
expect(user.currentStreak).toBe(1);
expect(user.tickets?.dailyAllKill).toBe(1);
expect(user.tierPoints).toBe(50);
});
});
describe("judgeWeekIfNeeded", () => {
async function seedWeekJudgments(
judgments: Record<DateString, DailyJudgment>
): Promise<void> {
for (const [date, j] of Object.entries(judgments)) {
await seedJudgedHistory(date as DateString, j);
}
}
it("화~일 6일 전부 success|perfect|skip 이면 weeklyMaster 지급", async () => {
await seedWeekJudgments({
[TUE]: "success",
[WED]: "perfect",
[THU]: "skip",
[FRI]: "success",
[SAT]: "success",
[SUN]: "success",
});
await judgeWeekIfNeeded(uid, SUN);
const user = await readUser();
expect(user.tickets?.weeklyMaster).toBe(1);
expect(user.lastWeeklyMasterTuesday).toBe(TUE);
});
it("한 날이라도 fail이면 지급 안 됨", async () => {
await seedWeekJudgments({
[TUE]: "success",
[WED]: "fail",
[THU]: "success",
[FRI]: "success",
[SAT]: "success",
[SUN]: "success",
});
await judgeWeekIfNeeded(uid, SUN);
const user = await readUser();
expect(user.tickets?.weeklyMaster ?? 0).toBe(0);
});
it("판정 이력이 누락된 날이 있으면 지급 안 됨", async () => {
await seedWeekJudgments({
[TUE]: "success",
[WED]: "success",
// THU 누락
[FRI]: "success",
[SAT]: "success",
[SUN]: "success",
});
await judgeWeekIfNeeded(uid, SUN);
const user = await readUser();
expect(user.tickets?.weeklyMaster ?? 0).toBe(0);
});
it("같은 주 재실행 시 중복 지급되지 않는다 (멱등)", async () => {
await seedWeekJudgments({
[TUE]: "success",
[WED]: "success",
[THU]: "success",
[FRI]: "success",
[SAT]: "success",
[SUN]: "success",
});
await judgeWeekIfNeeded(uid, SUN);
await judgeWeekIfNeeded(uid, SUN);
const user = await readUser();
expect(user.tickets?.weeklyMaster).toBe(1);
});
});
});

View File

@ -195,7 +195,6 @@ describe("statsService.getStats — 캐시 forDate 검증", () => {
progress: 0,
tier: "bronze",
tierPoints: 0,
tickets: { dailyAllKill: 0, weeklyMaster: 0 },
updatedAt: Date.now(),
forDate: today,
});
@ -222,7 +221,6 @@ describe("statsService.getStats — 캐시 forDate 검증", () => {
progress: 0,
tier: "bronze",
tierPoints: 0,
tickets: { dailyAllKill: 0, weeklyMaster: 0 },
updatedAt: Date.now() - 86_400_000,
forDate: yesterday,
});