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:
parent
852b56d00c
commit
2e4d01f985
@ -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);
|
||||
|
||||
@ -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
|
||||
};
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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");
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user