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") {
|
if (req.method === "GET" && path === "ledger") {
|
||||||
const page = await listLedger(uid, ...pageArgs(req));
|
const page = await listLedger(uid, ...pageArgs(req));
|
||||||
const dto: LedgerPageDto = {
|
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,
|
cursor: page.cursor,
|
||||||
};
|
};
|
||||||
res.json(dto);
|
res.json(dto);
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import type {Transaction} from "firebase-admin/firestore";
|
import type {Transaction} from "firebase-admin/firestore";
|
||||||
import {firestore} from "../firebase";
|
import {firestore} from "../firebase";
|
||||||
import type {PointLedgerEntry} from "../types/points";
|
import type {PointLedgerEntry} from "../types/points";
|
||||||
|
import type {StoredLedgerEntry} from "../types/dto/rewardDto";
|
||||||
|
|
||||||
function col(uid: string) { return firestore.collection(`users/${uid}/pointLedger`); }
|
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();
|
const snap = await q.get();
|
||||||
return {
|
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
|
cursor: snap.docs.length ? snap.docs[snap.docs.length - 1].id : null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import { toIso, toIsoOrUndefined } from "./iso";
|
import { toIso, toIsoOrUndefined } from "./iso";
|
||||||
|
import { OP_BY_TYPE } from "../points";
|
||||||
import type { PointLedgerEntry, PointLedgerType, PointOperation, WalletDoc } from "../points";
|
import type { PointLedgerEntry, PointLedgerType, PointOperation, WalletDoc } from "../points";
|
||||||
import type { OrderDoc, OrderStatus } from "../reward";
|
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 {
|
return {
|
||||||
id,
|
id,
|
||||||
txId: entry.txId,
|
txId: entry.txId ?? id,
|
||||||
uid: entry.uid,
|
uid: entry.uid ?? uid,
|
||||||
type: entry.type,
|
type: entry.type,
|
||||||
op: entry.op,
|
op,
|
||||||
amount: entry.amount,
|
amount: entry.amount,
|
||||||
availableBefore: entry.availableBefore,
|
availableBefore,
|
||||||
availableAfter: entry.availableAfter,
|
availableAfter,
|
||||||
reservedBefore: entry.reservedBefore,
|
reservedBefore: entry.reservedBefore ?? 0,
|
||||||
reservedAfter: entry.reservedAfter,
|
reservedAfter: entry.reservedAfter ?? 0,
|
||||||
relatedDate: entry.relatedDate,
|
relatedDate: entry.relatedDate ?? legacyRelatedDate(entry),
|
||||||
orderId: entry.orderId,
|
orderId: entry.orderId,
|
||||||
reversalOf: entry.reversalOf,
|
reversalOf: entry.reversalOf,
|
||||||
adminReason: entry.adminReason,
|
adminReason: entry.adminReason,
|
||||||
|
|||||||
@ -42,7 +42,7 @@ describe("포인트 내역 와이어 형식 (Firestore -> DTO)", () => {
|
|||||||
const page = await listLedger(uid, 20);
|
const page = await listLedger(uid, 20);
|
||||||
expect(page.items).toHaveLength(1);
|
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 });
|
const json = JSON.stringify({ items: dto, cursor: page.cursor });
|
||||||
|
|
||||||
expect(json).not.toContain("_seconds");
|
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", () => {
|
describe("toLedgerEntryDto", () => {
|
||||||
it("createdAt 이 UTC ISO 문자열이고 문서 id 가 붙는다", () => {
|
it("createdAt 이 UTC ISO 문자열이고 문서 id 가 붙는다", () => {
|
||||||
const dto = toLedgerEntryDto("entry-1", ledgerEntry);
|
const dto = toLedgerEntryDto("entry-1", "uid", ledgerEntry);
|
||||||
expectNoTimestampLeak(dto);
|
expectNoTimestampLeak(dto);
|
||||||
expect(dto.id).toBe("entry-1");
|
expect(dto.id).toBe("entry-1");
|
||||||
expect(dto.createdAt).toMatch(UTC_ISO);
|
expect(dto.createdAt).toMatch(UTC_ISO);
|
||||||
@ -99,12 +146,12 @@ describe("toLedgerEntryDto", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("relatedDate 의 YYYY-MM-DD 형식은 그대로 둔다", () => {
|
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("선택 필드가 없으면 응답에서 키가 사라진다", () => {
|
it("선택 필드가 없으면 응답에서 키가 사라진다", () => {
|
||||||
const minimal: PointLedgerEntry = { ...ledgerEntry, relatedDate: undefined };
|
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("relatedDate");
|
||||||
expect(json).not.toHaveProperty("orderId");
|
expect(json).not.toHaveProperty("orderId");
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user