From 85edc7c7e3647fb653748e7342ef269ad4fab018 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=EC=A0=95=EB=AF=BC?= Date: Tue, 21 Jul 2026 16:06:13 +0900 Subject: [PATCH] Simplify orders to immediate debit with single refund path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 주문 생성 시 예약(홀드) 없이 즉시 차감하고 confirmed 로 시작 — 상태 흐름을 확정 → 배송중 → 배송완료(+어드민 환불)로 간소화 - 유저 취소 엔드포인트(POST /orders/:id/cancel)와 cancelOrder 제거 — 상태 전이는 어드민 전용, 되돌림은 환불 전이 하나로 통일 (차감 txId 를 reversalOf 로 링크) - order_reserve/order_release 원장 타입과 reserve/capture/release op, 지갑 reservedBalance 제거 — 불변식을 available = earned - spent 로 단순화 - 이전 데이터 전면 삭제에 따라 2026-07 개편 이전 원장 문서용 레거시 정규화(StoredLedgerEntry, balanceAfter 역산, refMonth/refDay 합성)도 제거 - 주문/원장/지갑 테스트를 새 스키마로 갱신하고 orderService 에뮬레이터 테스트 6건 추가 (즉시 차감·잔액 부족·권한·전이 검증·환불 반환) --- src/handlers/rewardHandlers.ts | 10 +-- src/repositories/pointLedgerRepository.ts | 4 +- src/services/orderService.ts | 19 ++--- src/services/pointService.ts | 13 ++-- src/types/dto/rewardDto.ts | 71 +++--------------- src/types/points.ts | 12 +--- src/types/reward.ts | 18 +++-- tests/repositories/ledgerWireFormat.test.ts | 3 - tests/repositories/orderRepository.test.ts | 9 +-- tests/services/orderService.test.ts | 79 ++++++++++++++++++++- tests/types/rewardDto.test.ts | 74 +++---------------- 11 files changed, 132 insertions(+), 180 deletions(-) diff --git a/src/handlers/rewardHandlers.ts b/src/handlers/rewardHandlers.ts index 208bc48..b9ac20d 100644 --- a/src/handlers/rewardHandlers.ts +++ b/src/handlers/rewardHandlers.ts @@ -5,13 +5,12 @@ import { getWallet } from "../repositories/walletRepository"; import { listLedger } from "../repositories/pointLedgerRepository"; import { getCatalog, getCatalogProduct } from "../services/rewardCatalogService"; import { computeEligibility } from "../services/eligibilityService"; -import { cancelOrder, createOrder, getOrder, listOrders } from "../services/orderService"; +import { createOrder, getOrder, listOrders } from "../services/orderService"; import { EMPTY_WALLET_DTO, toLedgerEntryDto, toOrderDto, toWalletDto, - type CancelOrderDto, type CreateOrderResponseDto, type EligibilityDto, type LedgerPageDto, @@ -102,13 +101,6 @@ export const reward = onRequest(async (req, res) => { return; } - const cancel = path.match(/^orders\/([^/]+)\/cancel$/); - if (req.method === "POST" && cancel) { - const dto: CancelOrderDto = await cancelOrder(uid, cancel[1]); - res.json(dto); - return; - } - res.status(404).json({ error: "not found" }); } catch (err) { sendError(res, err); diff --git a/src/repositories/pointLedgerRepository.ts b/src/repositories/pointLedgerRepository.ts index 4de8b0a..6a5bdfb 100644 --- a/src/repositories/pointLedgerRepository.ts +++ b/src/repositories/pointLedgerRepository.ts @@ -1,7 +1,6 @@ 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`); } @@ -19,8 +18,7 @@ export async function listLedger(uid: string, limit = 20, cursor?: string) { } const snap = await q.get(); return { - // 개편 전 스키마 문서가 섞여 있어 StoredLedgerEntry 로 받는다. - items: snap.docs.map((d) => ({id: d.id, ...(d.data() as StoredLedgerEntry)})), + items: snap.docs.map((d) => ({id: d.id, ...(d.data() as PointLedgerEntry)})), cursor: snap.docs.length ? snap.docs[snap.docs.length - 1].id : null }; } \ No newline at end of file diff --git a/src/services/orderService.ts b/src/services/orderService.ts index 8f30a19..2b54ca1 100644 --- a/src/services/orderService.ts +++ b/src/services/orderService.ts @@ -39,22 +39,25 @@ export async function createOrder(uid: string, input: CreateOrderInput) { return { productId: item.productId, qty: item.qty, pointPrice: p.pointPrice, name: p.name, ...(option ? { optionId: option.id, optionName: option.name } : {}) }; }); const total = orderItems.reduce((n, i) => n + i.pointPrice * i.qty, 0); if ((currentWallet?.availableBalance ?? 0) < total) throw new HttpError(409, "insufficient balance", "INSUFFICIENT_BALANCE"); - const reserveId = `${uid}:order:${id}:reserve`; const wallet = await applyPointChangesTx(tx, uid, [{ txId: reserveId, type: PointLedgerType.OrderReserve, amount: total, orderId: id }]); - const order: OrderDoc = { uid, items: orderItems, totalPoints: total, recipient: input.recipient, status: "reserved", clientIdempotencyKey: input.clientIdempotencyKey, reserveLedgerTxIds: [reserveId], orderedAt: now, statusHistory: [{ status: "reserved", at: now, actor: uid }], createdAt: now, updatedAt: now }; + // 주문은 즉시 확정 — 홀드 없이 바로 차감하고, 되돌림은 어드민 환불로만 처리한다. + const debitTxId = `${uid}:order:${id}:debit`; const wallet = await applyPointChangesTx(tx, uid, [{ txId: debitTxId, type: PointLedgerType.OrderCapture, amount: total, orderId: id }]); + const order: OrderDoc = { uid, items: orderItems, totalPoints: total, recipient: input.recipient, status: "confirmed", clientIdempotencyKey: input.clientIdempotencyKey, debitLedgerTxId: debitTxId, orderedAt: now, confirmedAt: now, statusHistory: [{ status: "confirmed", at: now, actor: uid }], createdAt: now, updatedAt: now }; tx.create(orderRef(id), order); return { order: { id, ...order }, deduplicated: false, availableBalance: wallet!.availableBalance }; }); } -export async function transitionOrder(orderId: string, target: OrderStatus, actor: string, opts: { admin?: boolean; system?: boolean } = {}) { +export async function transitionOrder(orderId: string, target: OrderStatus, actor: string, opts: { admin?: boolean } = {}) { return firestore.runTransaction(async (tx) => { const snap = await tx.get(orderRef(orderId)); if (!snap.exists) throw new HttpError(404, "order not found", "ORDER_NOT_FOUND"); const order = snap.data() as OrderDoc; - if (!ORDER_TRANSITIONS[order.status].includes(target)) throw new HttpError(409, "invalid transition", "INVALID_ORDER_TRANSITION"); if (!opts.admin && !(order.uid === actor && order.status === "reserved" && target === "cancelled")) throw new HttpError(403, "forbidden", "FORBIDDEN"); + if (!ORDER_TRANSITIONS[order.status].includes(target)) throw new HttpError(409, "invalid transition", "INVALID_ORDER_TRANSITION"); + // 주문이 즉시 확정되므로 유저 측 전이(취소)는 없다 — 상태 변경은 어드민 전용. + if (!opts.admin) throw new HttpError(403, "forbidden", "FORBIDDEN"); const now = Timestamp.now(); - let type: PointLedgerType | null = null; let suffix = ""; if (target === "cancelled") { type = PointLedgerType.OrderRelease; suffix = "release"; } else if (target === "confirmed") { type = PointLedgerType.OrderCapture; suffix = "capture"; } else if (target === "refunded") { type = PointLedgerType.OrderRefund; suffix = "refund"; } - if (type) await applyPointChangesTx(tx, order.uid, [{ txId: `${order.uid}:order:${orderId}:${suffix}`, type, amount: order.totalPoints, orderId, reversalOf: order.reserveLedgerTxIds[0] }]); - const patch: Record = { status: target, updatedAt: now, statusHistory: [...order.statusHistory, { status: target, at: now, actor }] }; if (target === "cancelled") { patch.cancelledAt = now; patch.cancelledBy = actor; } if (target === "confirmed") patch.confirmedAt = now; if (target === "refunded") patch.refundedAt = now; tx.update(orderRef(orderId), patch); + if (target === "refunded") { + await applyPointChangesTx(tx, order.uid, [{ txId: `${order.uid}:order:${orderId}:refund`, type: PointLedgerType.OrderRefund, amount: order.totalPoints, orderId, reversalOf: order.debitLedgerTxId }]); + } + const patch: Record = { status: target, updatedAt: now, statusHistory: [...order.statusHistory, { status: target, at: now, actor }] }; if (target === "refunded") patch.refundedAt = now; tx.update(orderRef(orderId), patch); return { id: orderId, status: target }; }); } -export async function cancelOrder(uid: string, id: string) { const order = await getOrder(id); if (!order || order.uid !== uid) throw new HttpError(404, "order not found", "ORDER_NOT_FOUND"); return transitionOrder(id, "cancelled", uid); } export { getOrder, listOrders }; diff --git a/src/services/pointService.ts b/src/services/pointService.ts index d2463f5..1c1aaef 100644 --- a/src/services/pointService.ts +++ b/src/services/pointService.ts @@ -12,23 +12,20 @@ export async function applyPointChangesTx(tx: Transaction, uid: string, changes: 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 }; + const wallet: WalletDoc = existing ? { ...existing } : { availableBalance: 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]; + const beforeA = wallet.availableBalance; 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 }; + if (wallet.availableBalance < 0) throw new HttpError(409, "insufficient balance", "INSUFFICIENT_BALANCE"); + const entry: PointLedgerEntry = { ...c, uid, op, availableBefore: beforeA, availableAfter: wallet.availableBalance, createdAt: now }; createLedgerEntryTx(tx, uid, entry); } - if (wallet.availableBalance + wallet.reservedBalance !== wallet.totalEarned - wallet.totalSpent) throw new Error("wallet invariant violated"); + if (wallet.availableBalance !== wallet.totalEarned - wallet.totalSpent) throw new Error("wallet invariant violated"); wallet.version += 1; wallet.updatedAt = now; tx.set(walletDocRef(uid), wallet); return wallet; diff --git a/src/types/dto/rewardDto.ts b/src/types/dto/rewardDto.ts index 9dc8f69..744f143 100644 --- a/src/types/dto/rewardDto.ts +++ b/src/types/dto/rewardDto.ts @@ -1,5 +1,4 @@ import { toIso, toIsoOrUndefined } from "./iso"; -import { OP_BY_TYPE } from "../points"; import type { PointLedgerEntry, PointLedgerType, PointOperation, WalletDoc } from "../points"; import type { OrderDoc, OrderStatus, ProductDoc } from "../reward"; @@ -14,7 +13,6 @@ import type { OrderDoc, OrderStatus, ProductDoc } from "../reward"; export interface WalletDto { availableBalance: number; - reservedBalance: number; totalEarned: number; totalSpent: number; version: number; @@ -31,8 +29,6 @@ export interface LedgerEntryDto { amount: number; availableBefore: number; availableAfter: number; - reservedBefore: number; - reservedAfter: number; relatedDate?: string; orderId?: string; reversalOf?: string; @@ -78,14 +74,11 @@ export interface OrderDto { recipient: RecipientDto; status: OrderStatus; clientIdempotencyKey: string; - reserveLedgerTxIds: string[]; statusHistory: OrderStatusHistoryEntryDto[]; orderedAt: string; createdAt: string; updatedAt: string; - cancelledBy?: string; - cancelledAt?: string; - confirmedAt?: string; + confirmedAt: string; refundedAt?: string; } @@ -100,11 +93,6 @@ export interface CreateOrderResponseDto { availableBalance?: number; } -export interface CancelOrderDto { - id: string; - status: OrderStatus; -} - export interface ProductSummaryDto { id: string; name: string; @@ -144,7 +132,6 @@ export interface EligibilityDto { /** 지갑 문서가 없는 신규 유저는 잔액 0 으로 응답한다 (기존 동작 유지). */ export const EMPTY_WALLET_DTO: WalletDto = { availableBalance: 0, - reservedBalance: 0, totalEarned: 0, totalSpent: 0, version: 0, @@ -153,7 +140,6 @@ export const EMPTY_WALLET_DTO: WalletDto = { export function toWalletDto(doc: WalletDoc): WalletDto { return { availableBalance: doc.availableBalance, - reservedBalance: doc.reservedBalance, totalEarned: doc.totalEarned, totalSpent: doc.totalSpent, version: doc.version, @@ -162,58 +148,22 @@ export function toWalletDto(doc: WalletDoc): WalletDto { }; } -/** - * 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 에서 역산한다. - */ +/** 원장 항목 DTO — 필드 나열 조립 (스프레드 금지, Timestamp 유출 방지). */ export function toLedgerEntryDto( id: string, uid: string, - entry: StoredLedgerEntry, + entry: PointLedgerEntry, ): 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 ?? id, + txId: entry.txId, uid: entry.uid ?? uid, type: entry.type, - op, + op: entry.op, amount: entry.amount, - availableBefore, - availableAfter, - reservedBefore: entry.reservedBefore ?? 0, - reservedAfter: entry.reservedAfter ?? 0, - relatedDate: entry.relatedDate ?? legacyRelatedDate(entry), + availableBefore: entry.availableBefore, + availableAfter: entry.availableAfter, + relatedDate: entry.relatedDate, orderId: entry.orderId, reversalOf: entry.reversalOf, adminReason: entry.adminReason, @@ -274,7 +224,6 @@ export function toOrderDto(id: string, doc: OrderDoc): OrderDto { }, status: doc.status, clientIdempotencyKey: doc.clientIdempotencyKey, - reserveLedgerTxIds: doc.reserveLedgerTxIds, statusHistory: doc.statusHistory.map((h) => ({ status: h.status, at: toIso(h.at), @@ -283,9 +232,7 @@ export function toOrderDto(id: string, doc: OrderDoc): OrderDto { orderedAt: toIso(doc.orderedAt), createdAt: toIso(doc.createdAt), updatedAt: toIso(doc.updatedAt), - cancelledBy: doc.cancelledBy, - cancelledAt: toIsoOrUndefined(doc.cancelledAt), - confirmedAt: toIsoOrUndefined(doc.confirmedAt), + confirmedAt: toIso(doc.confirmedAt), refundedAt: toIsoOrUndefined(doc.refundedAt), }; } diff --git a/src/types/points.ts b/src/types/points.ts index 39ac5fc..94ad5a2 100644 --- a/src/types/points.ts +++ b/src/types/points.ts @@ -10,14 +10,13 @@ export enum PointLedgerType { EventCredit = "event_credit", AdminCredit = "admin_credit", AdminDebit = "admin_debit", - OrderReserve = "order_reserve", + /** 주문 생성 시 즉시 차감. (예약/확정 2단계 시절에는 확정 시점 차감이었다.) */ OrderCapture = "order_capture", - OrderRelease = "order_release", OrderRefund = "order_refund", PointExpiry = "point_expiry", } -export type PointOperation = "credit" | "debit" | "reserve" | "capture" | "release"; +export type PointOperation = "credit" | "debit"; export const OP_BY_TYPE: Record = { [PointLedgerType.AttendanceDaily]: "credit", @@ -29,16 +28,13 @@ export const OP_BY_TYPE: Record = { [PointLedgerType.EventCredit]: "credit", [PointLedgerType.AdminCredit]: "credit", [PointLedgerType.AdminDebit]: "debit", - [PointLedgerType.OrderReserve]: "reserve", - [PointLedgerType.OrderCapture]: "capture", - [PointLedgerType.OrderRelease]: "release", + [PointLedgerType.OrderCapture]: "debit", [PointLedgerType.OrderRefund]: "credit", [PointLedgerType.PointExpiry]: "debit", }; export interface WalletDoc { availableBalance: number; - reservedBalance: number; totalEarned: number; totalSpent: number; version: number; @@ -62,7 +58,5 @@ export interface PointLedgerEntry extends PointChange { op: PointOperation; availableBefore: number; availableAfter: number; - reservedBefore: number; - reservedAfter: number; createdAt: Timestamp; } diff --git a/src/types/reward.ts b/src/types/reward.ts index 1e9bf61..a2a3db8 100644 --- a/src/types/reward.ts +++ b/src/types/reward.ts @@ -26,11 +26,14 @@ export interface ProductDoc { createdAt: Timestamp; updatedAt: Timestamp; } -export type OrderStatus = "reserved" | "confirmed" | "preparing" | "shipped" | "delivered" | "cancelled" | "refunded"; +/** + * 주문은 생성 즉시 확정(포인트 즉시 차감)된다. 예약/확정 2단계와 유저 취소 구간은 + * 2026-07-21 간소화로 제거 — 되돌림은 어드민의 환불(refunded) 전이로만 처리한다. + */ +export type OrderStatus = "confirmed" | "shipped" | "delivered" | "refunded"; export const ORDER_TRANSITIONS: Record = { - reserved: ["confirmed", "cancelled"], confirmed: ["preparing", "refunded"], - preparing: ["shipped", "refunded"], shipped: ["delivered", "refunded"], - delivered: ["refunded"], cancelled: [], refunded: [], + confirmed: ["shipped", "refunded"], shipped: ["delivered", "refunded"], + delivered: ["refunded"], refunded: [], }; export interface OrderItem { productId: string; qty: number; pointPrice: number; name: string; @@ -40,9 +43,12 @@ export interface OrderItem { export interface Recipient { name: string; phone: string; address1: string; address2?: string; postalCode: string; deliveryMemo?: string } export interface OrderDoc { uid: string; items: OrderItem[]; totalPoints: number; recipient: Recipient; status: OrderStatus; - clientIdempotencyKey: string; reserveLedgerTxIds: string[]; orderedAt: Timestamp; + clientIdempotencyKey: string; + /** 주문 생성 시 즉시 차감한 원장 txId. 환불 시 reversalOf 링크로 쓴다. */ + debitLedgerTxId: string; + orderedAt: Timestamp; statusHistory: Array<{ status: OrderStatus; at: Timestamp; actor: string }>; - cancelledBy?: string; cancelledAt?: Timestamp; confirmedAt?: Timestamp; refundedAt?: Timestamp; + confirmedAt: Timestamp; refundedAt?: Timestamp; createdAt: Timestamp; updatedAt: Timestamp; } export interface EligibilityResult { diff --git a/tests/repositories/ledgerWireFormat.test.ts b/tests/repositories/ledgerWireFormat.test.ts index baba1dc..3e4bacc 100644 --- a/tests/repositories/ledgerWireFormat.test.ts +++ b/tests/repositories/ledgerWireFormat.test.ts @@ -33,8 +33,6 @@ describe("포인트 내역 와이어 형식 (Firestore -> DTO)", () => { amount: 20, availableBefore: 0, availableAfter: 20, - reservedBefore: 0, - reservedAfter: 0, relatedDate: "2026-07-20", createdAt, }); @@ -56,7 +54,6 @@ describe("포인트 내역 와이어 형식 (Firestore -> DTO)", () => { const now = Timestamp.fromDate(new Date("2026-07-20T05:30:00.000Z")); await firestore.doc(`users/${uid}/wallet/current`).set({ availableBalance: 20, - reservedBalance: 0, totalEarned: 20, totalSpent: 0, version: 1, diff --git a/tests/repositories/orderRepository.test.ts b/tests/repositories/orderRepository.test.ts index 11352c9..14c7dbb 100644 --- a/tests/repositories/orderRepository.test.ts +++ b/tests/repositories/orderRepository.test.ts @@ -10,11 +10,12 @@ function makeOrder(uid: string, createdAt: Timestamp, overrides: Partial { const t1 = Timestamp.fromDate(new Date("2026-07-01T00:00:00.000Z")); const t2 = Timestamp.fromDate(new Date("2026-07-02T00:00:00.000Z")); - await firestore.doc("orders/o1").set(makeOrder("uid-a", t1, { status: "reserved" })); + await firestore.doc("orders/o1").set(makeOrder("uid-a", t1, { status: "shipped" })); await firestore.doc("orders/o2").set(makeOrder("uid-b", t2, { status: "confirmed" })); const page = await listAllOrders(20, undefined, "confirmed"); diff --git a/tests/services/orderService.test.ts b/tests/services/orderService.test.ts index e697b07..dba138f 100644 --- a/tests/services/orderService.test.ts +++ b/tests/services/orderService.test.ts @@ -1,5 +1,7 @@ -import { describe, expect, it } from "vitest"; -import { resolveOrderOption } from "../../src/services/orderService"; +import { beforeAll, describe, expect, it } from "vitest"; +import { Timestamp } from "firebase-admin/firestore"; +import { firestore } from "../../src/firebase"; +import { createOrder, resolveOrderOption, transitionOrder } from "../../src/services/orderService"; import { HttpError } from "../../src/middleware/errors"; const options = [ @@ -35,3 +37,76 @@ describe("resolveOrderOption", () => { ); }); }); + +const uid = "order-service-test-user"; +const seedNow = Timestamp.now(); +const recipient = { name: "홍길동", phone: "01000000000", address1: "서울", postalCode: "00000" }; + +beforeAll(async () => { + // 잔액 1000P 지갑과 활성 상품을 시드한다. (불변식: available = earned - spent) + await firestore.doc(`users/${uid}/wallet/current`).set({ + availableBalance: 1000, totalEarned: 1000, totalSpent: 0, + version: 1, createdAt: seedNow, updatedAt: seedNow, + }); + await firestore.doc("products/ost-product").set({ + name: "테스트 키링", pointPrice: 300, active: true, redeemable: true, + mainImages: [], detailImages: [], displayOrder: 1, createdAt: seedNow, updatedAt: seedNow, + }); +}); + +describe("createOrder (즉시 차감)", () => { + it("주문이 confirmed 로 생성되고 포인트가 즉시 차감된다", async () => { + const result = await createOrder(uid, { + clientIdempotencyKey: "immediate-1", + items: [{ productId: "ost-product", qty: 1 }], + recipient, + }); + + expect(result.order.status).toBe("confirmed"); + expect(result.order.confirmedAt).toBeTruthy(); + expect(result.order.debitLedgerTxId).toBe(`${uid}:order:${result.order.id}:debit`); + expect(result.availableBalance).toBe(700); + + const entry = await firestore.doc(`users/${uid}/pointLedger/${result.order.debitLedgerTxId}`).get(); + expect(entry.exists).toBe(true); + expect(entry.data()).toMatchObject({ type: "order_capture", op: "debit", amount: 300 }); + }); + + it("잔액이 부족하면 409", async () => { + await expect( + createOrder(uid, { + clientIdempotencyKey: "too-expensive", + items: [{ productId: "ost-product", qty: 100 }], + recipient, + }), + ).rejects.toMatchObject({ status: 409 }); + }); +}); + +describe("transitionOrder (간소화된 전이)", () => { + it("유저(비어드민)는 어떤 전이도 할 수 없다", async () => { + await expect(transitionOrder(`${uid}_immediate-1`, "shipped", uid)).rejects.toMatchObject({ status: 403 }); + }); + + it("전이 테이블에 없는 전이는 409 (confirmed → delivered 직행 불가)", async () => { + await expect( + transitionOrder(`${uid}_immediate-1`, "delivered", "admin-uid", { admin: true }), + ).rejects.toMatchObject({ status: 409 }); + }); + + it("어드민 환불 시 포인트가 반환되고 차감 txId 가 reversalOf 로 링크된다", async () => { + const orderId = `${uid}_immediate-1`; + const result = await transitionOrder(orderId, "refunded", "admin-uid", { admin: true }); + expect(result.status).toBe("refunded"); + + const wallet = await firestore.doc(`users/${uid}/wallet/current`).get(); + expect(wallet.data()).toMatchObject({ availableBalance: 1000, totalSpent: 0 }); + + const refundEntry = await firestore.doc(`users/${uid}/pointLedger/${uid}:order:${orderId}:refund`).get(); + expect(refundEntry.exists).toBe(true); + expect(refundEntry.data()).toMatchObject({ + type: "order_refund", op: "credit", amount: 300, + reversalOf: `${uid}:order:${orderId}:debit`, + }); + }); +}); diff --git a/tests/types/rewardDto.test.ts b/tests/types/rewardDto.test.ts index b5742a1..4701ca6 100644 --- a/tests/types/rewardDto.test.ts +++ b/tests/types/rewardDto.test.ts @@ -28,7 +28,6 @@ function expectNoTimestampLeak(dto: unknown): void { const wallet: WalletDoc = { availableBalance: 1200, - reservedBalance: 300, totalEarned: 5000, totalSpent: 3500, version: 7, @@ -44,8 +43,6 @@ const ledgerEntry: PointLedgerEntry = { amount: 20, availableBefore: 1180, availableAfter: 1200, - reservedBefore: 300, - reservedAfter: 300, relatedDate: "2026-07-20", createdAt: ts("2026-07-20T05:30:00.000Z"), }; @@ -55,11 +52,12 @@ const baseOrder: OrderDoc = { items: [{ productId: "p1", qty: 2, pointPrice: 500, name: "굿즈" }], totalPoints: 1000, recipient: { name: "홍길동", phone: "01000000000", address1: "서울", postalCode: "00000" }, - status: "reserved", + status: "confirmed", clientIdempotencyKey: "key-1", - reserveLedgerTxIds: ["uid:order:o1:reserve"], + debitLedgerTxId: "uid:order:o1:debit", orderedAt: ts("2026-07-20T05:30:00.000Z"), - statusHistory: [{ status: "reserved", at: ts("2026-07-20T05:30:00.000Z"), actor: "uid" }], + confirmedAt: ts("2026-07-20T05:30:00.000Z"), + statusHistory: [{ status: "confirmed", at: ts("2026-07-20T05:30:00.000Z"), actor: "uid" }], createdAt: ts("2026-07-20T05:30:00.000Z"), updatedAt: ts("2026-07-20T05:30:00.000Z"), }; @@ -70,7 +68,6 @@ describe("toWalletDto", () => { expectNoTimestampLeak(dto); expect(wire(dto)).toEqual({ availableBalance: 1200, - reservedBalance: 300, totalEarned: 5000, totalSpent: 3500, version: 7, @@ -82,7 +79,6 @@ describe("toWalletDto", () => { it("지갑 문서가 없는 유저는 잔액 0 응답을 쓴다", () => { expect(wire(EMPTY_WALLET_DTO)).toEqual({ availableBalance: 0, - reservedBalance: 0, totalEarned: 0, totalSpent: 0, version: 0, @@ -90,53 +86,6 @@ 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", "uid", ledgerEntry); @@ -169,28 +118,21 @@ describe("toOrderDto", () => { expect(dto.statusHistory[0].at).toBe("2026-07-20T05:30:00.000Z"); }); - it("선택 날짜 필드가 채워지면 모두 문자열로 나간다", () => { + it("환불 주문은 refundedAt 도 문자열로 나간다", () => { const settled: OrderDoc = { ...baseOrder, status: "refunded", - cancelledBy: "admin", - cancelledAt: ts("2026-07-21T00:00:00.000Z"), - confirmedAt: ts("2026-07-22T00:00:00.000Z"), refundedAt: ts("2026-07-23T00:00:00.000Z"), }; const dto = toOrderDto("o1", settled); expectNoTimestampLeak(dto); - for (const value of [dto.cancelledAt, dto.confirmedAt, dto.refundedAt]) { - expect(value).toMatch(UTC_ISO); - } + expect(dto.confirmedAt).toMatch(UTC_ISO); + expect(dto.refundedAt).toMatch(UTC_ISO); }); - it("선택 날짜 필드가 비면 응답에서 키가 사라진다", () => { + it("환불 전에는 refundedAt 키가 응답에서 사라진다", () => { const json = wire(toOrderDto("o1", baseOrder)); - expect(json).not.toHaveProperty("cancelledAt"); - expect(json).not.toHaveProperty("confirmedAt"); expect(json).not.toHaveProperty("refundedAt"); - expect(json).not.toHaveProperty("cancelledBy"); }); it("문서에 없는 필드를 임의로 흘려보내지 않는다", () => {