Add shipment, refund reason, and option availability to orders
- shipped 전환 시 운송사·송장번호(shipment) 필수 검증 및 shippedAt/deliveredAt 기록, POST /admin/order/shipment로 배송정보 정정(배송 시작 이력에 감사 정보 동기화) - refunded 전환 시 관리자 환불 사유(2~500자) 필수 — 주문 문서·상태 이력·포인트 원장(adminReason/adminActor)에 기록하고 DTO로 노출 - 상품 옵션에 active 필드 추가(구 문서는 활성 간주) — 비활성 옵션 주문은 OPTION_UNAVAILABLE 거부, 활성·교환 가능 상품은 활성 옵션 1개 이상 필요 - OrderDto/OrderStatusHistoryEntryDto/ProductOptionDto 확장 및 관련 테스트 보강 - 수정 파일 줄바꿈 LF 정규화 및 eslint --fix 적용
This commit is contained in:
parent
2bcbb3ca77
commit
9a66d3fb12
@ -10,7 +10,7 @@ import { listAllOrders } from "../repositories/orderRepository";
|
||||
import { firestore } from "../firebase";
|
||||
import { Timestamp } from "firebase-admin/firestore";
|
||||
import type { OrderStatus, ProductDoc } from "../types/reward";
|
||||
import { transitionOrder } from "../services/orderService";
|
||||
import { transitionOrder, updateOrderShipment } from "../services/orderService";
|
||||
import { invalidateRewardCatalog } from "../services/rewardCatalogService";
|
||||
import { listAdminUsers, searchAdminUsers } from "../services/adminUserService";
|
||||
import {
|
||||
@ -33,7 +33,9 @@ function applyCors(req: { get(name: string): string | undefined }, res: { set(na
|
||||
export const admin = onRequest(async (req, res) => {
|
||||
applyCors(req, res);
|
||||
// preflight에는 Authorization 헤더가 없어 requireAdmin보다 먼저 처리해야 한다.
|
||||
if (req.method === "OPTIONS") { res.status(204).send(""); return; }
|
||||
if (req.method === "OPTIONS") {
|
||||
res.status(204).send(""); return;
|
||||
}
|
||||
|
||||
const segs = req.path.replace(/^\/+|\/+$/g, "").split("/");
|
||||
const tail = segs.slice(-2).join("/");
|
||||
@ -41,10 +43,16 @@ export const admin = onRequest(async (req, res) => {
|
||||
try {
|
||||
const adminUid = await requireAdmin(req);
|
||||
|
||||
if ((tail === "points/credit" || tail === "points/debit") && req.method === "POST") { const { uid, amount, clientIdempotencyKey, reason, reversalOf } = req.body ?? {}; res.json(await adminPointChange(uid, amount, tail.endsWith("credit") ? "credit" : "debit", clientIdempotencyKey, reason, adminUid, reversalOf)); return; }
|
||||
if ((tail === "points/credit" || tail === "points/debit") && req.method === "POST") {
|
||||
const { uid, amount, clientIdempotencyKey, reason, reversalOf } = req.body ?? {}; res.json(await adminPointChange(uid, amount, tail.endsWith("credit") ? "credit" : "debit", clientIdempotencyKey, reason, adminUid, reversalOf)); return;
|
||||
}
|
||||
// 지갑/원장도 DTO 를 거쳐 Timestamp 가 {_seconds,_nanoseconds} 로 새지 않게 한다 (어드민 웹이 브라우저에서 소비).
|
||||
if (tail === "points/wallet" && req.method === "GET") { const wallet = await getWallet(String(req.query.uid)); res.json(wallet ? toWalletDto(wallet) : null); return; }
|
||||
if (tail === "points/ledger" && req.method === "GET") { const uid = String(req.query.uid); const page = await listLedger(uid, Number(req.query.limit) || 20, typeof req.query.cursor === "string" ? req.query.cursor : undefined); const dto: LedgerPageDto = { items: page.items.map((entry) => toLedgerEntryDto(entry.id, uid, entry)), cursor: page.cursor }; res.json(dto); return; }
|
||||
if (tail === "points/wallet" && req.method === "GET") {
|
||||
const wallet = await getWallet(String(req.query.uid)); res.json(wallet ? toWalletDto(wallet) : null); return;
|
||||
}
|
||||
if (tail === "points/ledger" && req.method === "GET") {
|
||||
const uid = String(req.query.uid); const page = await listLedger(uid, Number(req.query.limit) || 20, typeof req.query.cursor === "string" ? req.query.cursor : undefined); const dto: LedgerPageDto = { items: page.items.map((entry) => toLedgerEntryDto(entry.id, uid, entry)), cursor: page.cursor }; res.json(dto); return;
|
||||
}
|
||||
if (tail === "product/upsert" && req.method === "POST") {
|
||||
const {
|
||||
id, name, pointPrice, active, redeemable, displayOrder,
|
||||
@ -60,9 +68,11 @@ export const admin = onRequest(async (req, res) => {
|
||||
(Array.isArray(options) && options.length >= 1 && options.length <= 20 &&
|
||||
options.every((o) =>
|
||||
typeof o?.id === "string" && /^[A-Za-z0-9_-]{1,100}$/.test(o.id) &&
|
||||
typeof o?.name === "string" && o.name.length >= 1 && o.name.length <= 100
|
||||
typeof o?.name === "string" && o.name.length >= 1 && o.name.length <= 100 &&
|
||||
(o.active === undefined || typeof o.active === "boolean")
|
||||
) &&
|
||||
new Set(options.map((o) => o.id)).size === options.length);
|
||||
const hasActiveOption = !Array.isArray(options) || options.some((o) => o?.active !== false);
|
||||
const valid =
|
||||
/^[A-Za-z0-9_-]{1,100}$/.test(id) &&
|
||||
typeof name === "string" && name.length >= 1 && name.length <= 100 &&
|
||||
@ -71,6 +81,7 @@ export const admin = onRequest(async (req, res) => {
|
||||
Number.isSafeInteger(displayOrder) && displayOrder >= 0 &&
|
||||
validUrls(mainImages, 10) && validUrls(detailImages, 30) &&
|
||||
(!active || !redeemable || mainImages.length > 0) &&
|
||||
(!active || !redeemable || hasActiveOption) &&
|
||||
validOptions &&
|
||||
(optionLabel === undefined ||
|
||||
(typeof optionLabel === "string" && optionLabel.length >= 1 && optionLabel.length <= 50));
|
||||
@ -83,12 +94,16 @@ export const admin = onRequest(async (req, res) => {
|
||||
tx.set(productRef, {
|
||||
name, pointPrice, active, redeemable, displayOrder,
|
||||
mainImages, detailImages,
|
||||
...(options !== undefined
|
||||
? {
|
||||
...(options !== undefined ?
|
||||
{
|
||||
optionLabel: optionLabel ?? "옵션",
|
||||
options: options.map((o: { id: string; name: string }) => ({ id: o.id, name: o.name })),
|
||||
}
|
||||
: {}),
|
||||
options: options.map((o: { id: string; name: string; active?: boolean }) => ({
|
||||
id: o.id,
|
||||
name: o.name,
|
||||
active: o.active !== false,
|
||||
})),
|
||||
} :
|
||||
{}),
|
||||
createdAt: previous?.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
});
|
||||
@ -97,7 +112,20 @@ export const admin = onRequest(async (req, res) => {
|
||||
res.json({ id });
|
||||
return;
|
||||
}
|
||||
if (tail === "order/status" && req.method === "POST") { const { orderId, status } = req.body ?? {}; res.json(await transitionOrder(orderId, status as OrderStatus, adminUid, { admin: true })); return; }
|
||||
if (tail === "order/status" && req.method === "POST") {
|
||||
const { orderId, status, shipment, refundReason } = req.body ?? {};
|
||||
res.json(await transitionOrder(orderId, status as OrderStatus, adminUid, {
|
||||
admin: true,
|
||||
shipment,
|
||||
refundReason,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (tail === "order/shipment" && req.method === "POST") {
|
||||
const { orderId, shipment } = req.body ?? {};
|
||||
res.json(await updateOrderShipment(orderId, adminUid, shipment));
|
||||
return;
|
||||
}
|
||||
|
||||
if (tail === "users/search" && req.method === "GET") {
|
||||
res.json(await searchAdminUsers(req.query.q, req.query.limit));
|
||||
|
||||
@ -6,14 +6,52 @@ import { productRef } from "../repositories/productRepository";
|
||||
import { applyPointChangesTx } from "./pointService";
|
||||
import { getWalletTx } from "../repositories/walletRepository";
|
||||
import { PointLedgerType } from "../types/points";
|
||||
import { ORDER_TRANSITIONS, type OrderDoc, type OrderItem, type OrderStatus, type ProductDoc, type ProductOption, type Recipient } from "../types/reward";
|
||||
import {
|
||||
ORDER_TRANSITIONS,
|
||||
type OrderDoc,
|
||||
type OrderItem,
|
||||
type OrderShipment,
|
||||
type OrderStatus,
|
||||
type ProductDoc,
|
||||
type ProductOption,
|
||||
type Recipient,
|
||||
} from "../types/reward";
|
||||
|
||||
const KEY = /^[A-Za-z0-9_-]{1,100}$/;
|
||||
export interface CreateOrderInput { clientIdempotencyKey: string; items: Array<{ productId: string; qty: number; optionId?: string }>; recipient: Recipient }
|
||||
function validateInput(i: CreateOrderInput) { if (!KEY.test(i?.clientIdempotencyKey ?? "") || !Array.isArray(i.items) || i.items.length < 1 || i.items.length > 20 || i.items.some((x) => !KEY.test(x.productId) || !Number.isSafeInteger(x.qty) || x.qty < 1 || x.qty > 100 || (x.optionId !== undefined && !KEY.test(x.optionId)))) throw new HttpError(400, "invalid order", "INVALID_INPUT"); const r = i.recipient; if (!r || [r.name, r.phone, r.address1, r.postalCode].some((v) => typeof v !== "string" || v.length < 1 || v.length > 200)) throw new HttpError(400, "invalid recipient", "INVALID_INPUT"); }
|
||||
function validateInput(i: CreateOrderInput) {
|
||||
if (!KEY.test(i?.clientIdempotencyKey ?? "") || !Array.isArray(i.items) || i.items.length < 1 || i.items.length > 20 || i.items.some((x) => !KEY.test(x.productId) || !Number.isSafeInteger(x.qty) || x.qty < 1 || x.qty > 100 || (x.optionId !== undefined && !KEY.test(x.optionId)))) throw new HttpError(400, "invalid order", "INVALID_INPUT"); const r = i.recipient; if (!r || [r.name, r.phone, r.address1, r.postalCode].some((v) => typeof v !== "string" || v.length < 1 || v.length > 200)) throw new HttpError(400, "invalid recipient", "INVALID_INPUT");
|
||||
}
|
||||
|
||||
interface TransitionOrderOptions {
|
||||
admin?: boolean;
|
||||
shipment?: OrderShipment;
|
||||
refundReason?: string;
|
||||
}
|
||||
|
||||
function normalizeShipment(input: OrderShipment | undefined): OrderShipment {
|
||||
const carrier = typeof input?.carrier === "string" ? input.carrier.trim() : "";
|
||||
const trackingNumber = typeof input?.trackingNumber === "string" ?
|
||||
input.trackingNumber.replace(/\s/g, "") : "";
|
||||
if (carrier.length < 1 || carrier.length > 50 || !/^[A-Za-z0-9-]{4,100}$/.test(trackingNumber)) {
|
||||
throw new HttpError(400, "invalid shipment", "INVALID_SHIPMENT");
|
||||
}
|
||||
return { carrier, trackingNumber };
|
||||
}
|
||||
|
||||
function normalizeRefundReason(input: string | undefined): string {
|
||||
const reason = typeof input === "string" ? input.trim() : "";
|
||||
if (reason.length < 2 || reason.length > 500) {
|
||||
throw new HttpError(400, "invalid refund reason", "INVALID_REFUND_REASON");
|
||||
}
|
||||
return reason;
|
||||
}
|
||||
|
||||
/** 옵션 상품이면 주문 항목의 optionId 를 실제 옵션으로 해석하고, 무옵션 상품이면 optionId 를 거부한다. */
|
||||
export function resolveOrderOption(product: Pick<ProductDoc, "options">, optionId: string | undefined): ProductOption | null {
|
||||
export function resolveOrderOption(
|
||||
product: Pick<ProductDoc, "options">,
|
||||
optionId: string | undefined,
|
||||
): ProductOption | null {
|
||||
const options = product.options ?? [];
|
||||
if (options.length === 0) {
|
||||
if (optionId !== undefined) throw new HttpError(400, "product has no options", "INVALID_INPUT");
|
||||
@ -21,6 +59,7 @@ export function resolveOrderOption(product: Pick<ProductDoc, "options">, optionI
|
||||
}
|
||||
const option = options.find((o) => o.id === optionId);
|
||||
if (!option) throw new HttpError(400, "option required", "OPTION_REQUIRED");
|
||||
if (option.active === false) throw new HttpError(409, "option unavailable", "OPTION_UNAVAILABLE");
|
||||
return option;
|
||||
}
|
||||
|
||||
@ -30,7 +69,9 @@ export async function createOrder(uid: string, input: CreateOrderInput) {
|
||||
const existing = await tx.get(orderRef(id)); if (existing.exists) return { order: { id, ...(existing.data() as OrderDoc) }, deduplicated: true };
|
||||
// 같은 상품의 서로 다른 옵션이 별개 항목으로 들어올 수 있으므로 상품 문서는 productId 당 한 번만 읽는다.
|
||||
// 재고는 별도 플랫폼에서 관리하므로 여기서는 검사·예약하지 않는다.
|
||||
const snapById = new Map<string, DocumentSnapshot>(); for (const item of input.items) { if (!snapById.has(item.productId)) snapById.set(item.productId, await tx.get(productRef(item.productId))); }
|
||||
const snapById = new Map<string, DocumentSnapshot>(); for (const item of input.items) {
|
||||
if (!snapById.has(item.productId)) snapById.set(item.productId, await tx.get(productRef(item.productId)));
|
||||
}
|
||||
const currentWallet = await getWalletTx(tx, uid);
|
||||
const orderItems: OrderItem[] = input.items.map((item) => {
|
||||
const p = snapById.get(item.productId)!.data() as ProductDoc | undefined;
|
||||
@ -46,18 +87,98 @@ export async function createOrder(uid: string, input: CreateOrderInput) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function transitionOrder(orderId: string, target: OrderStatus, actor: string, opts: { admin?: boolean } = {}) {
|
||||
export async function transitionOrder(
|
||||
orderId: string,
|
||||
target: OrderStatus,
|
||||
actor: string,
|
||||
opts: TransitionOrderOptions = {},
|
||||
) {
|
||||
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) throw new HttpError(403, "forbidden", "FORBIDDEN");
|
||||
const shipment = target === "shipped" ? normalizeShipment(opts.shipment) : undefined;
|
||||
const refundReason = target === "refunded" ? normalizeRefundReason(opts.refundReason) : undefined;
|
||||
const now = Timestamp.now();
|
||||
if (target === "refunded") {
|
||||
await applyPointChangesTx(tx, order.uid, [{ txId: `${order.uid}:order:${orderId}:refund`, type: PointLedgerType.OrderRefund, amount: order.totalPoints, orderId, reversalOf: order.debitLedgerTxId }]);
|
||||
await applyPointChangesTx(tx, order.uid, [{
|
||||
txId: `${order.uid}:order:${orderId}:refund`,
|
||||
type: PointLedgerType.OrderRefund,
|
||||
amount: order.totalPoints,
|
||||
orderId,
|
||||
reversalOf: order.debitLedgerTxId,
|
||||
adminReason: refundReason,
|
||||
adminActor: actor,
|
||||
}]);
|
||||
}
|
||||
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);
|
||||
const patch: Record<string, unknown> = {
|
||||
status: target,
|
||||
updatedAt: now,
|
||||
statusHistory: [
|
||||
...order.statusHistory,
|
||||
{
|
||||
status: target,
|
||||
at: now,
|
||||
actor,
|
||||
...(shipment ? { shipment } : {}),
|
||||
...(refundReason ? { refundReason } : {}),
|
||||
},
|
||||
],
|
||||
};
|
||||
if (target === "shipped") {
|
||||
patch.shipment = shipment;
|
||||
patch.shippedAt = now;
|
||||
}
|
||||
if (target === "delivered") patch.deliveredAt = now;
|
||||
if (target === "refunded") {
|
||||
patch.refundedAt = now;
|
||||
patch.refundReason = refundReason;
|
||||
}
|
||||
tx.update(orderRef(orderId), patch);
|
||||
return { id: orderId, status: target };
|
||||
});
|
||||
}
|
||||
|
||||
/** 배송 시작 이후 운송사·송장번호를 정정하고 배송 시작 이력에도 동기화한다. */
|
||||
export async function updateOrderShipment(
|
||||
orderId: string,
|
||||
actor: string,
|
||||
input: OrderShipment | undefined,
|
||||
) {
|
||||
if (typeof orderId !== "string" || !/^[A-Za-z0-9_-]{1,256}$/.test(orderId)) {
|
||||
throw new HttpError(400, "invalid order id", "INVALID_INPUT");
|
||||
}
|
||||
const shipment = normalizeShipment(input);
|
||||
return firestore.runTransaction(async (tx) => {
|
||||
const ref = orderRef(orderId);
|
||||
const snap = await tx.get(ref);
|
||||
if (!snap.exists) throw new HttpError(404, "order not found", "ORDER_NOT_FOUND");
|
||||
const order = snap.data() as OrderDoc;
|
||||
let shippedHistoryIndex = -1;
|
||||
for (let index = order.statusHistory.length - 1; index >= 0; index -= 1) {
|
||||
if (order.statusHistory[index].status === "shipped") {
|
||||
shippedHistoryIndex = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (shippedHistoryIndex < 0) {
|
||||
throw new HttpError(409, "shipment not registered", "SHIPMENT_NOT_REGISTERED");
|
||||
}
|
||||
|
||||
const now = Timestamp.now();
|
||||
const statusHistory = order.statusHistory.map((entry, index) =>
|
||||
index === shippedHistoryIndex ?
|
||||
{
|
||||
...entry,
|
||||
shipment,
|
||||
shipmentUpdatedAt: now,
|
||||
shipmentUpdatedBy: actor,
|
||||
} :
|
||||
entry
|
||||
);
|
||||
tx.update(ref, { shipment, statusHistory, updatedAt: now });
|
||||
return { id: orderId, shipment };
|
||||
});
|
||||
}
|
||||
export { getOrder, listOrders };
|
||||
|
||||
@ -43,12 +43,16 @@ export async function getCatalogProduct(id: string): Promise<ProductDetailDto |
|
||||
detailImages: product.detailImages,
|
||||
// 옵션이 있는 상품만 라벨과 선택지를 내려보낸다. 필드를 하나씩 옮겨 담아
|
||||
// 문서에 다른 필드가 생겨도 응답으로 새지 않게 한다.
|
||||
...(product.options?.length
|
||||
? {
|
||||
...(product.options?.length ?
|
||||
{
|
||||
optionLabel: product.optionLabel ?? "옵션",
|
||||
options: product.options.map((o) => ({ id: o.id, name: o.name })),
|
||||
}
|
||||
: {}),
|
||||
options: product.options.map((o) => ({
|
||||
id: o.id,
|
||||
name: o.name,
|
||||
active: o.active !== false,
|
||||
})),
|
||||
} :
|
||||
{}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@ -64,6 +64,15 @@ export interface OrderStatusHistoryEntryDto {
|
||||
status: OrderStatus;
|
||||
at: string;
|
||||
actor: string;
|
||||
shipment?: OrderShipmentDto;
|
||||
shipmentUpdatedAt?: string;
|
||||
shipmentUpdatedBy?: string;
|
||||
refundReason?: string;
|
||||
}
|
||||
|
||||
export interface OrderShipmentDto {
|
||||
carrier: string;
|
||||
trackingNumber: string;
|
||||
}
|
||||
|
||||
export interface OrderDto {
|
||||
@ -74,11 +83,15 @@ export interface OrderDto {
|
||||
recipient: RecipientDto;
|
||||
status: OrderStatus;
|
||||
clientIdempotencyKey: string;
|
||||
shipment?: OrderShipmentDto;
|
||||
refundReason?: string;
|
||||
statusHistory: OrderStatusHistoryEntryDto[];
|
||||
orderedAt: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
confirmedAt: string;
|
||||
shippedAt?: string;
|
||||
deliveredAt?: string;
|
||||
refundedAt?: string;
|
||||
}
|
||||
|
||||
@ -107,6 +120,7 @@ export interface ProductSummaryDto {
|
||||
export interface ProductOptionDto {
|
||||
id: string;
|
||||
name: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface ProductDetailDto extends ProductSummaryDto {
|
||||
@ -186,12 +200,12 @@ export function toAdminProductDto(id: string, doc: ProductDoc): AdminProductDto
|
||||
displayOrder: doc.displayOrder,
|
||||
mainImages: doc.mainImages,
|
||||
detailImages: doc.detailImages,
|
||||
...(doc.options?.length
|
||||
? {
|
||||
...(doc.options?.length ?
|
||||
{
|
||||
optionLabel: doc.optionLabel ?? "옵션",
|
||||
options: doc.options.map((o) => ({ id: o.id, name: o.name })),
|
||||
}
|
||||
: {}),
|
||||
options: doc.options.map((o) => ({ id: o.id, name: o.name, active: o.active !== false })),
|
||||
} :
|
||||
{}),
|
||||
createdAt: toIso(doc.createdAt),
|
||||
updatedAt: toIso(doc.updatedAt),
|
||||
};
|
||||
@ -224,15 +238,29 @@ export function toOrderDto(id: string, doc: OrderDoc): OrderDto {
|
||||
},
|
||||
status: doc.status,
|
||||
clientIdempotencyKey: doc.clientIdempotencyKey,
|
||||
shipment: doc.shipment ? {
|
||||
carrier: doc.shipment.carrier,
|
||||
trackingNumber: doc.shipment.trackingNumber,
|
||||
} : undefined,
|
||||
refundReason: doc.refundReason,
|
||||
statusHistory: doc.statusHistory.map((h) => ({
|
||||
status: h.status,
|
||||
at: toIso(h.at),
|
||||
actor: h.actor,
|
||||
shipment: h.shipment ? {
|
||||
carrier: h.shipment.carrier,
|
||||
trackingNumber: h.shipment.trackingNumber,
|
||||
} : undefined,
|
||||
shipmentUpdatedAt: toIsoOrUndefined(h.shipmentUpdatedAt),
|
||||
shipmentUpdatedBy: h.shipmentUpdatedBy,
|
||||
refundReason: h.refundReason,
|
||||
})),
|
||||
orderedAt: toIso(doc.orderedAt),
|
||||
createdAt: toIso(doc.createdAt),
|
||||
updatedAt: toIso(doc.updatedAt),
|
||||
confirmedAt: toIso(doc.confirmedAt),
|
||||
shippedAt: toIsoOrUndefined(doc.shippedAt),
|
||||
deliveredAt: toIsoOrUndefined(doc.deliveredAt),
|
||||
refundedAt: toIsoOrUndefined(doc.refundedAt),
|
||||
};
|
||||
}
|
||||
|
||||
@ -7,6 +7,8 @@ import type { Timestamp } from "firebase-admin/firestore";
|
||||
export interface ProductOption {
|
||||
id: string;
|
||||
name: string;
|
||||
/** 구 문서에는 필드가 없을 수 있으며, 그 경우 활성으로 간주한다. */
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
export interface ProductDoc {
|
||||
@ -41,14 +43,33 @@ export interface OrderItem {
|
||||
optionId?: string; optionName?: string;
|
||||
}
|
||||
export interface Recipient { name: string; phone: string; address1: string; address2?: string; postalCode: string; deliveryMemo?: string }
|
||||
export interface OrderShipment {
|
||||
carrier: string;
|
||||
trackingNumber: string;
|
||||
}
|
||||
export interface OrderStatusHistoryEntry {
|
||||
status: OrderStatus;
|
||||
at: Timestamp;
|
||||
actor: string;
|
||||
/** 배송 시작 이력에 당시 배송정보를 함께 보관한다. */
|
||||
shipment?: OrderShipment;
|
||||
/** 배송정보를 나중에 수정한 경우의 감사 정보. */
|
||||
shipmentUpdatedAt?: Timestamp;
|
||||
shipmentUpdatedBy?: string;
|
||||
/** 환불 처리 시 관리자가 입력한 사유. */
|
||||
refundReason?: string;
|
||||
}
|
||||
export interface OrderDoc {
|
||||
uid: string; items: OrderItem[]; totalPoints: number; recipient: Recipient; status: OrderStatus;
|
||||
clientIdempotencyKey: string;
|
||||
/** 주문 생성 시 즉시 차감한 원장 txId. 환불 시 reversalOf 링크로 쓴다. */
|
||||
debitLedgerTxId: string;
|
||||
orderedAt: Timestamp;
|
||||
statusHistory: Array<{ status: OrderStatus; at: Timestamp; actor: string }>;
|
||||
confirmedAt: Timestamp; refundedAt?: Timestamp;
|
||||
shipment?: OrderShipment;
|
||||
/** 클라이언트가 처리이력 전체를 해석하지 않고 표시할 수 있는 최종 환불 사유. */
|
||||
refundReason?: string;
|
||||
statusHistory: OrderStatusHistoryEntry[];
|
||||
confirmedAt: Timestamp; shippedAt?: Timestamp; deliveredAt?: Timestamp; refundedAt?: Timestamp;
|
||||
createdAt: Timestamp; updatedAt: Timestamp;
|
||||
}
|
||||
export interface EligibilityResult {
|
||||
|
||||
@ -1,12 +1,17 @@
|
||||
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 {
|
||||
createOrder,
|
||||
resolveOrderOption,
|
||||
transitionOrder,
|
||||
updateOrderShipment,
|
||||
} from "../../src/services/orderService";
|
||||
import { HttpError } from "../../src/middleware/errors";
|
||||
|
||||
const options = [
|
||||
{ id: "blue", name: "블루" },
|
||||
{ id: "red", name: "레드" },
|
||||
{ id: "red", name: "레드", active: false },
|
||||
];
|
||||
|
||||
describe("resolveOrderOption", () => {
|
||||
@ -94,11 +99,94 @@ describe("transitionOrder (간소화된 전이)", () => {
|
||||
).rejects.toMatchObject({ status: 409 });
|
||||
});
|
||||
|
||||
it("어드민 환불 시 포인트가 반환되고 차감 txId 가 reversalOf 로 링크된다", async () => {
|
||||
it("비활성 옵션은 OPTION_UNAVAILABLE 로 거부한다", () => {
|
||||
expect(() => resolveOrderOption({ options }, "red")).toThrowError(
|
||||
expect.objectContaining({ status: 409, code: "OPTION_UNAVAILABLE" }) as HttpError,
|
||||
);
|
||||
});
|
||||
|
||||
it("배송 시작에는 택배사와 송장번호가 필수다", async () => {
|
||||
await expect(
|
||||
transitionOrder(`${uid}_immediate-1`, "shipped", "admin-uid", { admin: true }),
|
||||
).rejects.toMatchObject({ status: 400, code: "INVALID_SHIPMENT" });
|
||||
});
|
||||
|
||||
it("배송 시작 이력이 없는 주문의 배송정보는 수정할 수 없다", async () => {
|
||||
await expect(
|
||||
updateOrderShipment(`${uid}_immediate-1`, "admin-uid", {
|
||||
carrier: "CJ대한통운",
|
||||
trackingNumber: "1234567890",
|
||||
}),
|
||||
).rejects.toMatchObject({ status: 409, code: "SHIPMENT_NOT_REGISTERED" });
|
||||
});
|
||||
|
||||
it("배송 시작 시 배송 정보와 발송 시각을 저장한다", async () => {
|
||||
const orderId = `${uid}_immediate-1`;
|
||||
const result = await transitionOrder(orderId, "refunded", "admin-uid", { admin: true });
|
||||
const result = await transitionOrder(orderId, "shipped", "admin-uid", {
|
||||
admin: true,
|
||||
shipment: { carrier: " CJ대한통운 ", trackingNumber: "1234 5678-90" },
|
||||
});
|
||||
expect(result.status).toBe("shipped");
|
||||
|
||||
const order = await firestore.doc(`orders/${orderId}`).get();
|
||||
expect(order.data()).toMatchObject({
|
||||
status: "shipped",
|
||||
shipment: { carrier: "CJ대한통운", trackingNumber: "12345678-90" },
|
||||
statusHistory: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
status: "shipped",
|
||||
shipment: { carrier: "CJ대한통운", trackingNumber: "12345678-90" },
|
||||
}),
|
||||
]),
|
||||
});
|
||||
expect(order.data()?.shippedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it("배송정보를 수정하면 주문과 배송 시작 이력을 함께 갱신한다", async () => {
|
||||
const orderId = `${uid}_immediate-1`;
|
||||
const result = await updateOrderShipment(orderId, "shipment-editor", {
|
||||
carrier: " 우체국택배 ",
|
||||
trackingNumber: "9876 5432-10",
|
||||
});
|
||||
expect(result.shipment).toEqual({ carrier: "우체국택배", trackingNumber: "98765432-10" });
|
||||
|
||||
const order = await firestore.doc(`orders/${orderId}`).get();
|
||||
expect(order.data()?.shipment).toEqual({ carrier: "우체국택배", trackingNumber: "98765432-10" });
|
||||
const shippedHistory = order.data()?.statusHistory.find(
|
||||
(entry: { status: string }) => entry.status === "shipped",
|
||||
);
|
||||
expect(shippedHistory).toMatchObject({
|
||||
shipment: { carrier: "우체국택배", trackingNumber: "98765432-10" },
|
||||
shipmentUpdatedBy: "shipment-editor",
|
||||
});
|
||||
expect(shippedHistory.shipmentUpdatedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it("환불 사유가 없으면 환불할 수 없다", async () => {
|
||||
await expect(
|
||||
transitionOrder(`${uid}_immediate-1`, "refunded", "admin-uid", { admin: true }),
|
||||
).rejects.toMatchObject({ status: 400, code: "INVALID_REFUND_REASON" });
|
||||
});
|
||||
|
||||
it("어드민 환불 시 사유를 저장하고 차감 포인트를 반환한다", async () => {
|
||||
const orderId = `${uid}_immediate-1`;
|
||||
const result = await transitionOrder(orderId, "refunded", "admin-uid", {
|
||||
admin: true,
|
||||
refundReason: " 상품 파손으로 교환 취소 ",
|
||||
});
|
||||
expect(result.status).toBe("refunded");
|
||||
|
||||
const order = await firestore.doc(`orders/${orderId}`).get();
|
||||
expect(order.data()).toMatchObject({
|
||||
refundReason: "상품 파손으로 교환 취소",
|
||||
statusHistory: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
status: "refunded",
|
||||
refundReason: "상품 파손으로 교환 취소",
|
||||
}),
|
||||
]),
|
||||
});
|
||||
|
||||
const wallet = await firestore.doc(`users/${uid}/wallet/current`).get();
|
||||
expect(wallet.data()).toMatchObject({ availableBalance: 1000, totalSpent: 0 });
|
||||
|
||||
@ -107,6 +195,8 @@ describe("transitionOrder (간소화된 전이)", () => {
|
||||
expect(refundEntry.data()).toMatchObject({
|
||||
type: "order_refund", op: "credit", amount: 300,
|
||||
reversalOf: `${uid}:order:${orderId}:debit`,
|
||||
adminReason: "상품 파손으로 교환 취소",
|
||||
adminActor: "admin-uid",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -78,20 +78,20 @@ describe("rewardCatalogService option contract", () => {
|
||||
optionLabel: "색상",
|
||||
options: [
|
||||
{ id: "blue", name: "블루" },
|
||||
{ id: "red", name: "레드" },
|
||||
{ id: "red", name: "레드", active: false },
|
||||
],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
});
|
||||
|
||||
it("상세에는 옵션 라벨과 선택지를 문서 순서대로 노출한다", async () => {
|
||||
it("상세에는 비활성 상태를 포함한 옵션을 문서 순서대로 노출한다", async () => {
|
||||
expect(await getCatalogProduct(optionId)).toMatchObject({
|
||||
id: optionId,
|
||||
optionLabel: "색상",
|
||||
options: [
|
||||
{ id: "blue", name: "블루" },
|
||||
{ id: "red", name: "레드" },
|
||||
{ id: "blue", name: "블루", active: true },
|
||||
{ id: "red", name: "레드", active: false },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
@ -122,17 +122,67 @@ describe("toOrderDto", () => {
|
||||
const settled: OrderDoc = {
|
||||
...baseOrder,
|
||||
status: "refunded",
|
||||
refundReason: "고객 요청으로 교환 취소",
|
||||
refundedAt: ts("2026-07-23T00:00:00.000Z"),
|
||||
statusHistory: [
|
||||
...baseOrder.statusHistory,
|
||||
{
|
||||
status: "refunded",
|
||||
at: ts("2026-07-23T00:00:00.000Z"),
|
||||
actor: "admin-uid",
|
||||
refundReason: "고객 요청으로 교환 취소",
|
||||
},
|
||||
],
|
||||
};
|
||||
const dto = toOrderDto("o1", settled);
|
||||
expectNoTimestampLeak(dto);
|
||||
expect(dto.confirmedAt).toMatch(UTC_ISO);
|
||||
expect(dto.refundedAt).toMatch(UTC_ISO);
|
||||
expect(dto.refundReason).toBe("고객 요청으로 교환 취소");
|
||||
expect(dto.statusHistory[1].refundReason).toBe("고객 요청으로 교환 취소");
|
||||
});
|
||||
|
||||
it("환불 전에는 refundedAt 키가 응답에서 사라진다", () => {
|
||||
const json = wire(toOrderDto("o1", baseOrder));
|
||||
expect(json).not.toHaveProperty("refundedAt");
|
||||
expect(json).not.toHaveProperty("refundReason");
|
||||
});
|
||||
|
||||
it("배송 정보와 배송 시각을 명시적으로 내보낸다", () => {
|
||||
const shippedAt = ts("2026-07-21T02:00:00.000Z");
|
||||
const shipmentUpdatedAt = ts("2026-07-21T03:00:00.000Z");
|
||||
const shipped: OrderDoc = {
|
||||
...baseOrder,
|
||||
status: "shipped",
|
||||
shipment: { carrier: "CJ대한통운", trackingNumber: "1234567890" },
|
||||
shippedAt,
|
||||
statusHistory: [
|
||||
...baseOrder.statusHistory,
|
||||
{
|
||||
status: "shipped",
|
||||
at: shippedAt,
|
||||
actor: "shipping-admin",
|
||||
shipment: { carrier: "CJ대한통운", trackingNumber: "1234567890" },
|
||||
shipmentUpdatedAt,
|
||||
shipmentUpdatedBy: "shipment-editor",
|
||||
},
|
||||
],
|
||||
};
|
||||
const json = wire(toOrderDto("o1", shipped));
|
||||
expect(json.shipment).toEqual({ carrier: "CJ대한통운", trackingNumber: "1234567890" });
|
||||
expect(json.shippedAt).toBe("2026-07-21T02:00:00.000Z");
|
||||
expect(json.statusHistory[1]).toMatchObject({
|
||||
shipment: { carrier: "CJ대한통운", trackingNumber: "1234567890" },
|
||||
shipmentUpdatedAt: "2026-07-21T03:00:00.000Z",
|
||||
shipmentUpdatedBy: "shipment-editor",
|
||||
});
|
||||
});
|
||||
|
||||
it("배송 전에는 배송 정보와 배송 시각 키가 응답에서 사라진다", () => {
|
||||
const json = wire(toOrderDto("o1", baseOrder));
|
||||
expect(json).not.toHaveProperty("shipment");
|
||||
expect(json).not.toHaveProperty("shippedAt");
|
||||
expect(json).not.toHaveProperty("deliveredAt");
|
||||
});
|
||||
|
||||
it("문서에 없는 필드를 임의로 흘려보내지 않는다", () => {
|
||||
@ -194,7 +244,23 @@ describe("toAdminProductDto", () => {
|
||||
};
|
||||
const json = wire(toAdminProductDto("p1", withOptions));
|
||||
expect(json.optionLabel).toBe("색상");
|
||||
expect(json.options).toEqual([{ id: "blue", name: "블루" }]);
|
||||
expect(json.options).toEqual([{ id: "blue", name: "블루", active: true }]);
|
||||
});
|
||||
|
||||
it("옵션 활성 상태를 어드민 응답에 명시한다", () => {
|
||||
const withOptions: ProductDoc = {
|
||||
...baseProduct,
|
||||
optionLabel: "색상",
|
||||
options: [
|
||||
{ id: "blue", name: "블루" },
|
||||
{ id: "red", name: "레드", active: false },
|
||||
],
|
||||
};
|
||||
const json = wire(toAdminProductDto("p1", withOptions));
|
||||
expect(json.options).toEqual([
|
||||
{ id: "blue", name: "블루", active: true },
|
||||
{ id: "red", name: "레드", active: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("옵션이 없으면 optionLabel/options 키가 응답에서 사라진다", () => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user