Normalize pre-migration ledger documents in the response

- 포인트 시스템 개편(2026-07-16) 이전 스키마 문서가 운영 Firestore에 그대로 남아 있어 포인트 내역이 실패하던 문제 수정
- 구 문서에는 txId/uid/op/available*/reserved*가 없고 balanceAfter/refMonth/refDay가 있다
- 읽기 타입을 StoredLedgerEntry로 넓히고, DTO에서 남은 값으로 복원한다
  - txId는 문서 id, uid는 요청 uid, op는 type에서, 거래 전 잔액은 balanceAfter에서 역산
  - refMonth + refDay를 relatedDate(YYYY-MM-DD)로 합침
- 실제 운영 문서 형태를 그대로 쓴 회귀 테스트 추가
This commit is contained in:
윤정민 2026-07-20 20:50:57 +09:00
parent 852b56d00c
commit 2e4d01f985
5 changed files with 105 additions and 16 deletions

View File

@ -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);

View File

@ -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
};
}

View File

@ -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<PointLedgerEntry> &
Pick<PointLedgerEntry, "type" | "amount" | "createdAt"> & {
/** 구 스키마 — 거래 후 잔액. */
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,

View File

@ -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");

View File

@ -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");
});