diff --git a/src/handlers/rewardHandlers.ts b/src/handlers/rewardHandlers.ts index 14dd072..208bc48 100644 --- a/src/handlers/rewardHandlers.ts +++ b/src/handlers/rewardHandlers.ts @@ -40,7 +40,7 @@ export const reward = onRequest(async (req, res) => { if (req.method === "GET" && path === "ledger") { const page = await listLedger(uid, ...pageArgs(req)); const dto: LedgerPageDto = { - items: page.items.map((entry) => toLedgerEntryDto(entry.id, entry)), + items: page.items.map((entry) => toLedgerEntryDto(entry.id, uid, entry)), cursor: page.cursor, }; res.json(dto); diff --git a/src/repositories/pointLedgerRepository.ts b/src/repositories/pointLedgerRepository.ts index 0cc0985..4de8b0a 100644 --- a/src/repositories/pointLedgerRepository.ts +++ b/src/repositories/pointLedgerRepository.ts @@ -1,6 +1,7 @@ import type {Transaction} from "firebase-admin/firestore"; import {firestore} from "../firebase"; import type {PointLedgerEntry} from "../types/points"; +import type {StoredLedgerEntry} from "../types/dto/rewardDto"; function col(uid: string) { return firestore.collection(`users/${uid}/pointLedger`); } @@ -18,7 +19,8 @@ export async function listLedger(uid: string, limit = 20, cursor?: string) { } const snap = await q.get(); return { - items: snap.docs.map((d) => ({id: d.id, ...(d.data() as PointLedgerEntry)})), + // 개편 전 스키마 문서가 섞여 있어 StoredLedgerEntry 로 받는다. + items: snap.docs.map((d) => ({id: d.id, ...(d.data() as StoredLedgerEntry)})), cursor: snap.docs.length ? snap.docs[snap.docs.length - 1].id : null }; -} +} \ No newline at end of file diff --git a/src/types/dto/rewardDto.ts b/src/types/dto/rewardDto.ts index ec017a0..85c05dd 100644 --- a/src/types/dto/rewardDto.ts +++ b/src/types/dto/rewardDto.ts @@ -1,4 +1,5 @@ import { toIso, toIsoOrUndefined } from "./iso"; +import { OP_BY_TYPE } from "../points"; import type { PointLedgerEntry, PointLedgerType, PointOperation, WalletDoc } from "../points"; import type { OrderDoc, OrderStatus } from "../reward"; @@ -143,19 +144,58 @@ export function toWalletDto(doc: WalletDoc): WalletDto { }; } -export function toLedgerEntryDto(id: string, entry: PointLedgerEntry): LedgerEntryDto { +/** + * Firestore 에 실제로 남아 있는 원장 문서. + * + * 2026-07-16 포인트 시스템 개편 이전 문서가 그대로 섞여 있다. 구 문서에는 + * `txId`/`uid`/`op`/`available*`/`reserved*` 가 없고 대신 `balanceAfter` 와 + * `refMonth`/`refDay` 가 있다. 읽기 경로는 두 세대를 모두 받아야 한다 — + * 필수로 선언하면 옛 출석 기록 한 건 때문에 포인트 내역 화면 전체가 죽는다. + */ +export type StoredLedgerEntry = Partial & + Pick & { + /** 구 스키마 — 거래 후 잔액. */ + balanceAfter?: number; + /** 구 스키마 — `YYYY-MM`. */ + refMonth?: string; + /** 구 스키마 — 일(1~31). */ + refDay?: number; + }; + +/** 구 스키마의 refMonth + refDay 를 `YYYY-MM-DD` 로 합친다. */ +function legacyRelatedDate(entry: StoredLedgerEntry): string | undefined { + const { refMonth, refDay } = entry; + if (refMonth === undefined || refDay === undefined) return undefined; + return `${refMonth}-${String(refDay).padStart(2, "0")}`; +} + +/** + * 원장 항목 DTO. 구 스키마 문서는 남아 있는 값에서 복원한다 — + * 문서 id 가 곧 txId 이고, op 는 type 에서, 거래 전 잔액은 balanceAfter 에서 역산한다. + */ +export function toLedgerEntryDto( + id: string, + uid: string, + entry: StoredLedgerEntry, +): LedgerEntryDto { + const op = entry.op ?? OP_BY_TYPE[entry.type] ?? "credit"; + const availableAfter = entry.availableAfter ?? entry.balanceAfter ?? 0; + const availableBefore = + entry.availableBefore ?? + (op === "credit" ? availableAfter - entry.amount : availableAfter + entry.amount); + return { id, - txId: entry.txId, - uid: entry.uid, + txId: entry.txId ?? id, + uid: entry.uid ?? uid, type: entry.type, - op: entry.op, + op, amount: entry.amount, - availableBefore: entry.availableBefore, - availableAfter: entry.availableAfter, - reservedBefore: entry.reservedBefore, - reservedAfter: entry.reservedAfter, - relatedDate: entry.relatedDate, + availableBefore, + availableAfter, + reservedBefore: entry.reservedBefore ?? 0, + reservedAfter: entry.reservedAfter ?? 0, + relatedDate: entry.relatedDate ?? legacyRelatedDate(entry), orderId: entry.orderId, reversalOf: entry.reversalOf, adminReason: entry.adminReason, diff --git a/tests/repositories/ledgerWireFormat.test.ts b/tests/repositories/ledgerWireFormat.test.ts index 2ea3791..baba1dc 100644 --- a/tests/repositories/ledgerWireFormat.test.ts +++ b/tests/repositories/ledgerWireFormat.test.ts @@ -42,7 +42,7 @@ describe("포인트 내역 와이어 형식 (Firestore -> DTO)", () => { const page = await listLedger(uid, 20); expect(page.items).toHaveLength(1); - const dto = page.items.map((entry) => toLedgerEntryDto(entry.id, entry)); + const dto = page.items.map((entry) => toLedgerEntryDto(entry.id, uid, entry)); const json = JSON.stringify({ items: dto, cursor: page.cursor }); expect(json).not.toContain("_seconds"); diff --git a/tests/types/rewardDto.test.ts b/tests/types/rewardDto.test.ts index af03fa5..c064176 100644 --- a/tests/types/rewardDto.test.ts +++ b/tests/types/rewardDto.test.ts @@ -89,9 +89,56 @@ describe("toWalletDto", () => { }); }); +describe("toLedgerEntryDto — 개편 전 스키마 문서", () => { + // 운영 Firestore 에 남아 있는 실제 형태 (users/{uid}/pointLedger, 자동 ID). + // txId/uid/op/available*/reserved* 가 없다. + const legacyDoc = { + type: PointLedgerType.AttendanceDaily, + amount: 10, + balanceAfter: 40, + refMonth: "2026-07", + refDay: 13, + createdAt: ts("2026-07-13T04:36:26.958Z"), + }; + + it("필수 필드가 없어도 응답을 만들어 낸다 (한 건 때문에 화면 전체가 죽지 않도록)", () => { + const dto = toLedgerEntryDto("0J4PNDtrJDZujvaCjO5G", "uid-1", legacyDoc); + expectNoTimestampLeak(dto); + expect(dto.txId).toBe("0J4PNDtrJDZujvaCjO5G"); + expect(dto.uid).toBe("uid-1"); + expect(dto.createdAt).toMatch(UTC_ISO); + }); + + it("op 를 type 에서 복원한다", () => { + expect(toLedgerEntryDto("legacy", "uid-1", legacyDoc).op).toBe("credit"); + }); + + it("balanceAfter 로 거래 전후 잔액을 복원한다", () => { + const dto = toLedgerEntryDto("legacy", "uid-1", legacyDoc); + expect(dto.availableAfter).toBe(40); + expect(dto.availableBefore).toBe(30); + expect(dto.reservedBefore).toBe(0); + expect(dto.reservedAfter).toBe(0); + }); + + it("refMonth + refDay 를 relatedDate 로 합친다", () => { + expect(toLedgerEntryDto("legacy", "uid-1", legacyDoc).relatedDate).toBe("2026-07-13"); + }); + + it("응답에 필수 필드가 하나도 빠지지 않는다 (클라이언트가 as String 캐스트를 한다)", () => { + const json = wire(toLedgerEntryDto("legacy", "uid-1", legacyDoc)); + for (const key of [ + "id", "txId", "uid", "type", "op", "amount", + "availableBefore", "availableAfter", "reservedBefore", "reservedAfter", "createdAt", + ]) { + expect(json[key], `$key 누락`).not.toBeUndefined(); + } + }); +}); + describe("toLedgerEntryDto", () => { it("createdAt 이 UTC ISO 문자열이고 문서 id 가 붙는다", () => { - const dto = toLedgerEntryDto("entry-1", ledgerEntry); + const dto = toLedgerEntryDto("entry-1", "uid", ledgerEntry); expectNoTimestampLeak(dto); expect(dto.id).toBe("entry-1"); expect(dto.createdAt).toMatch(UTC_ISO); @@ -99,12 +146,12 @@ describe("toLedgerEntryDto", () => { }); it("relatedDate 의 YYYY-MM-DD 형식은 그대로 둔다", () => { - expect(toLedgerEntryDto("entry-1", ledgerEntry).relatedDate).toBe("2026-07-20"); + expect(toLedgerEntryDto("entry-1", "uid", ledgerEntry).relatedDate).toBe("2026-07-20"); }); it("선택 필드가 없으면 응답에서 키가 사라진다", () => { const minimal: PointLedgerEntry = { ...ledgerEntry, relatedDate: undefined }; - const json = wire(toLedgerEntryDto("entry-1", minimal)); + const json = wire(toLedgerEntryDto("entry-1", "uid", minimal)); expect(json).not.toHaveProperty("relatedDate"); expect(json).not.toHaveProperty("orderId"); });