Simplify orders to immediate debit with single refund path

- 주문 생성 시 예약(홀드) 없이 즉시 차감하고 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건 추가 (즉시 차감·잔액 부족·권한·전이 검증·환불 반환)
This commit is contained in:
윤정민 2026-07-21 16:06:13 +09:00
parent d5aca61f4f
commit 85edc7c7e3
11 changed files with 132 additions and 180 deletions

View File

@ -5,13 +5,12 @@ import { getWallet } from "../repositories/walletRepository";
import { listLedger } from "../repositories/pointLedgerRepository"; import { listLedger } from "../repositories/pointLedgerRepository";
import { getCatalog, getCatalogProduct } from "../services/rewardCatalogService"; import { getCatalog, getCatalogProduct } from "../services/rewardCatalogService";
import { computeEligibility } from "../services/eligibilityService"; import { computeEligibility } from "../services/eligibilityService";
import { cancelOrder, createOrder, getOrder, listOrders } from "../services/orderService"; import { createOrder, getOrder, listOrders } from "../services/orderService";
import { import {
EMPTY_WALLET_DTO, EMPTY_WALLET_DTO,
toLedgerEntryDto, toLedgerEntryDto,
toOrderDto, toOrderDto,
toWalletDto, toWalletDto,
type CancelOrderDto,
type CreateOrderResponseDto, type CreateOrderResponseDto,
type EligibilityDto, type EligibilityDto,
type LedgerPageDto, type LedgerPageDto,
@ -102,13 +101,6 @@ export const reward = onRequest(async (req, res) => {
return; 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" }); res.status(404).json({ error: "not found" });
} catch (err) { } catch (err) {
sendError(res, err); sendError(res, err);

View File

@ -1,7 +1,6 @@
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`); }
@ -19,8 +18,7 @@ export async function listLedger(uid: string, limit = 20, cursor?: string) {
} }
const snap = await q.get(); const snap = await q.get();
return { return {
// 개편 전 스키마 문서가 섞여 있어 StoredLedgerEntry 로 받는다. items: snap.docs.map((d) => ({id: d.id, ...(d.data() as PointLedgerEntry)})),
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
}; };
} }

View File

@ -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 } : {}) }; 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 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 }; 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) => { 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; 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(); 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 (target === "refunded") {
if (type) await applyPointChangesTx(tx, order.uid, [{ txId: `${order.uid}:order:${orderId}:${suffix}`, type, amount: order.totalPoints, orderId, reversalOf: order.reserveLedgerTxIds[0] }]); await applyPointChangesTx(tx, order.uid, [{ txId: `${order.uid}:order:${orderId}:refund`, type: PointLedgerType.OrderRefund, amount: order.totalPoints, orderId, reversalOf: order.debitLedgerTxId }]);
const patch: Record<string, unknown> = { 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); }
const patch: Record<string, unknown> = { 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 }; 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 }; export { getOrder, listOrders };

View File

@ -12,23 +12,20 @@ export async function applyPointChangesTx(tx: Transaction, uid: string, changes:
const existing = await getWalletTx(tx, uid); const existing = await getWalletTx(tx, uid);
if (changes.length === 0) return existing; if (changes.length === 0) return existing;
const now = Timestamp.now(); 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) { 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") { if (op === "credit") {
wallet.availableBalance += c.amount; wallet.availableBalance += c.amount;
if (c.type === PointLedgerType.OrderRefund) wallet.totalSpent -= c.amount; if (c.type === PointLedgerType.OrderRefund) wallet.totalSpent -= c.amount;
else wallet.totalEarned += c.amount; else wallet.totalEarned += c.amount;
} }
if (op === "debit") { wallet.availableBalance -= c.amount; wallet.totalSpent += 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 (wallet.availableBalance < 0) throw new HttpError(409, "insufficient balance", "INSUFFICIENT_BALANCE");
if (op === "capture") { wallet.reservedBalance -= c.amount; wallet.totalSpent += c.amount; } const entry: PointLedgerEntry = { ...c, uid, op, availableBefore: beforeA, availableAfter: wallet.availableBalance, createdAt: now };
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 };
createLedgerEntryTx(tx, uid, entry); 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; wallet.version += 1; wallet.updatedAt = now;
tx.set(walletDocRef(uid), wallet); tx.set(walletDocRef(uid), wallet);
return wallet; return wallet;

View File

@ -1,5 +1,4 @@
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, ProductDoc } from "../reward"; import type { OrderDoc, OrderStatus, ProductDoc } from "../reward";
@ -14,7 +13,6 @@ import type { OrderDoc, OrderStatus, ProductDoc } from "../reward";
export interface WalletDto { export interface WalletDto {
availableBalance: number; availableBalance: number;
reservedBalance: number;
totalEarned: number; totalEarned: number;
totalSpent: number; totalSpent: number;
version: number; version: number;
@ -31,8 +29,6 @@ export interface LedgerEntryDto {
amount: number; amount: number;
availableBefore: number; availableBefore: number;
availableAfter: number; availableAfter: number;
reservedBefore: number;
reservedAfter: number;
relatedDate?: string; relatedDate?: string;
orderId?: string; orderId?: string;
reversalOf?: string; reversalOf?: string;
@ -78,14 +74,11 @@ export interface OrderDto {
recipient: RecipientDto; recipient: RecipientDto;
status: OrderStatus; status: OrderStatus;
clientIdempotencyKey: string; clientIdempotencyKey: string;
reserveLedgerTxIds: string[];
statusHistory: OrderStatusHistoryEntryDto[]; statusHistory: OrderStatusHistoryEntryDto[];
orderedAt: string; orderedAt: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
cancelledBy?: string; confirmedAt: string;
cancelledAt?: string;
confirmedAt?: string;
refundedAt?: string; refundedAt?: string;
} }
@ -100,11 +93,6 @@ export interface CreateOrderResponseDto {
availableBalance?: number; availableBalance?: number;
} }
export interface CancelOrderDto {
id: string;
status: OrderStatus;
}
export interface ProductSummaryDto { export interface ProductSummaryDto {
id: string; id: string;
name: string; name: string;
@ -144,7 +132,6 @@ export interface EligibilityDto {
/** 지갑 문서가 없는 신규 유저는 잔액 0 으로 응답한다 (기존 동작 유지). */ /** 지갑 문서가 없는 신규 유저는 잔액 0 으로 응답한다 (기존 동작 유지). */
export const EMPTY_WALLET_DTO: WalletDto = { export const EMPTY_WALLET_DTO: WalletDto = {
availableBalance: 0, availableBalance: 0,
reservedBalance: 0,
totalEarned: 0, totalEarned: 0,
totalSpent: 0, totalSpent: 0,
version: 0, version: 0,
@ -153,7 +140,6 @@ export const EMPTY_WALLET_DTO: WalletDto = {
export function toWalletDto(doc: WalletDoc): WalletDto { export function toWalletDto(doc: WalletDoc): WalletDto {
return { return {
availableBalance: doc.availableBalance, availableBalance: doc.availableBalance,
reservedBalance: doc.reservedBalance,
totalEarned: doc.totalEarned, totalEarned: doc.totalEarned,
totalSpent: doc.totalSpent, totalSpent: doc.totalSpent,
version: doc.version, version: doc.version,
@ -162,58 +148,22 @@ export function toWalletDto(doc: WalletDoc): WalletDto {
}; };
} }
/** /** 원장 항목 DTO — 필드 나열 조립 (스프레드 금지, Timestamp 유출 방지). */
* 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( export function toLedgerEntryDto(
id: string, id: string,
uid: string, uid: string,
entry: StoredLedgerEntry, entry: PointLedgerEntry,
): LedgerEntryDto { ): 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 ?? id, txId: entry.txId,
uid: entry.uid ?? uid, uid: entry.uid ?? uid,
type: entry.type, type: entry.type,
op, op: entry.op,
amount: entry.amount, amount: entry.amount,
availableBefore, availableBefore: entry.availableBefore,
availableAfter, availableAfter: entry.availableAfter,
reservedBefore: entry.reservedBefore ?? 0, relatedDate: entry.relatedDate,
reservedAfter: entry.reservedAfter ?? 0,
relatedDate: entry.relatedDate ?? legacyRelatedDate(entry),
orderId: entry.orderId, orderId: entry.orderId,
reversalOf: entry.reversalOf, reversalOf: entry.reversalOf,
adminReason: entry.adminReason, adminReason: entry.adminReason,
@ -274,7 +224,6 @@ export function toOrderDto(id: string, doc: OrderDoc): OrderDto {
}, },
status: doc.status, status: doc.status,
clientIdempotencyKey: doc.clientIdempotencyKey, clientIdempotencyKey: doc.clientIdempotencyKey,
reserveLedgerTxIds: doc.reserveLedgerTxIds,
statusHistory: doc.statusHistory.map((h) => ({ statusHistory: doc.statusHistory.map((h) => ({
status: h.status, status: h.status,
at: toIso(h.at), at: toIso(h.at),
@ -283,9 +232,7 @@ export function toOrderDto(id: string, doc: OrderDoc): OrderDto {
orderedAt: toIso(doc.orderedAt), orderedAt: toIso(doc.orderedAt),
createdAt: toIso(doc.createdAt), createdAt: toIso(doc.createdAt),
updatedAt: toIso(doc.updatedAt), updatedAt: toIso(doc.updatedAt),
cancelledBy: doc.cancelledBy, confirmedAt: toIso(doc.confirmedAt),
cancelledAt: toIsoOrUndefined(doc.cancelledAt),
confirmedAt: toIsoOrUndefined(doc.confirmedAt),
refundedAt: toIsoOrUndefined(doc.refundedAt), refundedAt: toIsoOrUndefined(doc.refundedAt),
}; };
} }

View File

@ -10,14 +10,13 @@ export enum PointLedgerType {
EventCredit = "event_credit", EventCredit = "event_credit",
AdminCredit = "admin_credit", AdminCredit = "admin_credit",
AdminDebit = "admin_debit", AdminDebit = "admin_debit",
OrderReserve = "order_reserve", /** 주문 생성 시 즉시 차감. (예약/확정 2단계 시절에는 확정 시점 차감이었다.) */
OrderCapture = "order_capture", OrderCapture = "order_capture",
OrderRelease = "order_release",
OrderRefund = "order_refund", OrderRefund = "order_refund",
PointExpiry = "point_expiry", PointExpiry = "point_expiry",
} }
export type PointOperation = "credit" | "debit" | "reserve" | "capture" | "release"; export type PointOperation = "credit" | "debit";
export const OP_BY_TYPE: Record<PointLedgerType, PointOperation> = { export const OP_BY_TYPE: Record<PointLedgerType, PointOperation> = {
[PointLedgerType.AttendanceDaily]: "credit", [PointLedgerType.AttendanceDaily]: "credit",
@ -29,16 +28,13 @@ export const OP_BY_TYPE: Record<PointLedgerType, PointOperation> = {
[PointLedgerType.EventCredit]: "credit", [PointLedgerType.EventCredit]: "credit",
[PointLedgerType.AdminCredit]: "credit", [PointLedgerType.AdminCredit]: "credit",
[PointLedgerType.AdminDebit]: "debit", [PointLedgerType.AdminDebit]: "debit",
[PointLedgerType.OrderReserve]: "reserve", [PointLedgerType.OrderCapture]: "debit",
[PointLedgerType.OrderCapture]: "capture",
[PointLedgerType.OrderRelease]: "release",
[PointLedgerType.OrderRefund]: "credit", [PointLedgerType.OrderRefund]: "credit",
[PointLedgerType.PointExpiry]: "debit", [PointLedgerType.PointExpiry]: "debit",
}; };
export interface WalletDoc { export interface WalletDoc {
availableBalance: number; availableBalance: number;
reservedBalance: number;
totalEarned: number; totalEarned: number;
totalSpent: number; totalSpent: number;
version: number; version: number;
@ -62,7 +58,5 @@ export interface PointLedgerEntry extends PointChange {
op: PointOperation; op: PointOperation;
availableBefore: number; availableBefore: number;
availableAfter: number; availableAfter: number;
reservedBefore: number;
reservedAfter: number;
createdAt: Timestamp; createdAt: Timestamp;
} }

View File

@ -26,11 +26,14 @@ export interface ProductDoc {
createdAt: Timestamp; createdAt: Timestamp;
updatedAt: 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<OrderStatus, readonly OrderStatus[]> = { export const ORDER_TRANSITIONS: Record<OrderStatus, readonly OrderStatus[]> = {
reserved: ["confirmed", "cancelled"], confirmed: ["preparing", "refunded"], confirmed: ["shipped", "refunded"], shipped: ["delivered", "refunded"],
preparing: ["shipped", "refunded"], shipped: ["delivered", "refunded"], delivered: ["refunded"], refunded: [],
delivered: ["refunded"], cancelled: [], refunded: [],
}; };
export interface OrderItem { export interface OrderItem {
productId: string; qty: number; pointPrice: number; name: string; 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 Recipient { name: string; phone: string; address1: string; address2?: string; postalCode: string; deliveryMemo?: string }
export interface OrderDoc { export interface OrderDoc {
uid: string; items: OrderItem[]; totalPoints: number; recipient: Recipient; status: OrderStatus; 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 }>; statusHistory: Array<{ status: OrderStatus; at: Timestamp; actor: string }>;
cancelledBy?: string; cancelledAt?: Timestamp; confirmedAt?: Timestamp; refundedAt?: Timestamp; confirmedAt: Timestamp; refundedAt?: Timestamp;
createdAt: Timestamp; updatedAt: Timestamp; createdAt: Timestamp; updatedAt: Timestamp;
} }
export interface EligibilityResult { export interface EligibilityResult {

View File

@ -33,8 +33,6 @@ describe("포인트 내역 와이어 형식 (Firestore -> DTO)", () => {
amount: 20, amount: 20,
availableBefore: 0, availableBefore: 0,
availableAfter: 20, availableAfter: 20,
reservedBefore: 0,
reservedAfter: 0,
relatedDate: "2026-07-20", relatedDate: "2026-07-20",
createdAt, createdAt,
}); });
@ -56,7 +54,6 @@ describe("포인트 내역 와이어 형식 (Firestore -> DTO)", () => {
const now = Timestamp.fromDate(new Date("2026-07-20T05:30:00.000Z")); const now = Timestamp.fromDate(new Date("2026-07-20T05:30:00.000Z"));
await firestore.doc(`users/${uid}/wallet/current`).set({ await firestore.doc(`users/${uid}/wallet/current`).set({
availableBalance: 20, availableBalance: 20,
reservedBalance: 0,
totalEarned: 20, totalEarned: 20,
totalSpent: 0, totalSpent: 0,
version: 1, version: 1,

View File

@ -10,11 +10,12 @@ function makeOrder(uid: string, createdAt: Timestamp, overrides: Partial<OrderDo
items: [{ productId: "p1", qty: 1, pointPrice: 500, name: "굿즈" }], items: [{ productId: "p1", qty: 1, pointPrice: 500, name: "굿즈" }],
totalPoints: 500, totalPoints: 500,
recipient: { name: "홍길동", phone: "01000000000", address1: "서울", postalCode: "00000" }, recipient: { name: "홍길동", phone: "01000000000", address1: "서울", postalCode: "00000" },
status: "reserved", status: "confirmed",
clientIdempotencyKey: `key-${uid}-${createdAt.toMillis()}`, clientIdempotencyKey: `key-${uid}-${createdAt.toMillis()}`,
reserveLedgerTxIds: [], debitLedgerTxId: `${uid}:order:test:debit`,
orderedAt: createdAt, orderedAt: createdAt,
statusHistory: [{ status: "reserved", at: createdAt, actor: uid }], confirmedAt: createdAt,
statusHistory: [{ status: "confirmed", at: createdAt, actor: uid }],
createdAt, createdAt,
updatedAt: createdAt, updatedAt: createdAt,
...overrides, ...overrides,
@ -62,7 +63,7 @@ describe("orderRepository.listAllOrders", () => {
const t1 = Timestamp.fromDate(new Date("2026-07-01T00:00:00.000Z")); const t1 = Timestamp.fromDate(new Date("2026-07-01T00:00:00.000Z"));
const t2 = Timestamp.fromDate(new Date("2026-07-02T00: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" })); await firestore.doc("orders/o2").set(makeOrder("uid-b", t2, { status: "confirmed" }));
const page = await listAllOrders(20, undefined, "confirmed"); const page = await listAllOrders(20, undefined, "confirmed");

View File

@ -1,5 +1,7 @@
import { describe, expect, it } from "vitest"; import { beforeAll, describe, expect, it } from "vitest";
import { resolveOrderOption } from "../../src/services/orderService"; 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"; import { HttpError } from "../../src/middleware/errors";
const options = [ 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`,
});
});
});

View File

@ -28,7 +28,6 @@ function expectNoTimestampLeak(dto: unknown): void {
const wallet: WalletDoc = { const wallet: WalletDoc = {
availableBalance: 1200, availableBalance: 1200,
reservedBalance: 300,
totalEarned: 5000, totalEarned: 5000,
totalSpent: 3500, totalSpent: 3500,
version: 7, version: 7,
@ -44,8 +43,6 @@ const ledgerEntry: PointLedgerEntry = {
amount: 20, amount: 20,
availableBefore: 1180, availableBefore: 1180,
availableAfter: 1200, availableAfter: 1200,
reservedBefore: 300,
reservedAfter: 300,
relatedDate: "2026-07-20", relatedDate: "2026-07-20",
createdAt: ts("2026-07-20T05:30:00.000Z"), createdAt: ts("2026-07-20T05:30:00.000Z"),
}; };
@ -55,11 +52,12 @@ const baseOrder: OrderDoc = {
items: [{ productId: "p1", qty: 2, pointPrice: 500, name: "굿즈" }], items: [{ productId: "p1", qty: 2, pointPrice: 500, name: "굿즈" }],
totalPoints: 1000, totalPoints: 1000,
recipient: { name: "홍길동", phone: "01000000000", address1: "서울", postalCode: "00000" }, recipient: { name: "홍길동", phone: "01000000000", address1: "서울", postalCode: "00000" },
status: "reserved", status: "confirmed",
clientIdempotencyKey: "key-1", clientIdempotencyKey: "key-1",
reserveLedgerTxIds: ["uid:order:o1:reserve"], debitLedgerTxId: "uid:order:o1:debit",
orderedAt: ts("2026-07-20T05:30:00.000Z"), 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"), createdAt: ts("2026-07-20T05:30:00.000Z"),
updatedAt: ts("2026-07-20T05:30:00.000Z"), updatedAt: ts("2026-07-20T05:30:00.000Z"),
}; };
@ -70,7 +68,6 @@ describe("toWalletDto", () => {
expectNoTimestampLeak(dto); expectNoTimestampLeak(dto);
expect(wire(dto)).toEqual({ expect(wire(dto)).toEqual({
availableBalance: 1200, availableBalance: 1200,
reservedBalance: 300,
totalEarned: 5000, totalEarned: 5000,
totalSpent: 3500, totalSpent: 3500,
version: 7, version: 7,
@ -82,7 +79,6 @@ describe("toWalletDto", () => {
it("지갑 문서가 없는 유저는 잔액 0 응답을 쓴다", () => { it("지갑 문서가 없는 유저는 잔액 0 응답을 쓴다", () => {
expect(wire(EMPTY_WALLET_DTO)).toEqual({ expect(wire(EMPTY_WALLET_DTO)).toEqual({
availableBalance: 0, availableBalance: 0,
reservedBalance: 0,
totalEarned: 0, totalEarned: 0,
totalSpent: 0, totalSpent: 0,
version: 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", () => { describe("toLedgerEntryDto", () => {
it("createdAt 이 UTC ISO 문자열이고 문서 id 가 붙는다", () => { it("createdAt 이 UTC ISO 문자열이고 문서 id 가 붙는다", () => {
const dto = toLedgerEntryDto("entry-1", "uid", ledgerEntry); 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"); expect(dto.statusHistory[0].at).toBe("2026-07-20T05:30:00.000Z");
}); });
it("선택 날짜 필드가 채워지면 모두 문자열로 나간다", () => { it("환불 주문은 refundedAt 도 문자열로 나간다", () => {
const settled: OrderDoc = { const settled: OrderDoc = {
...baseOrder, ...baseOrder,
status: "refunded", 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"), refundedAt: ts("2026-07-23T00:00:00.000Z"),
}; };
const dto = toOrderDto("o1", settled); const dto = toOrderDto("o1", settled);
expectNoTimestampLeak(dto); expectNoTimestampLeak(dto);
for (const value of [dto.cancelledAt, dto.confirmedAt, dto.refundedAt]) { expect(dto.confirmedAt).toMatch(UTC_ISO);
expect(value).toMatch(UTC_ISO); expect(dto.refundedAt).toMatch(UTC_ISO);
}
}); });
it("선택 날짜 필드가 비면 응답에서 키가 사라진다", () => { it("환불 전에는 refundedAt 키가 응답에서 사라진다", () => {
const json = wire(toOrderDto("o1", baseOrder)); 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("refundedAt");
expect(json).not.toHaveProperty("cancelledBy");
}); });
it("문서에 없는 필드를 임의로 흘려보내지 않는다", () => { it("문서에 없는 필드를 임의로 흘려보내지 않는다", () => {