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:
parent
d5aca61f4f
commit
85edc7c7e3
@ -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);
|
||||||
|
|||||||
@ -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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -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 };
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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");
|
||||||
|
|||||||
@ -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`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -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("문서에 없는 필드를 임의로 흘려보내지 않는다", () => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user