From 9a66d3fb12cf0a3dc53cb93eb3ca9a93f316da7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=EC=A0=95=EB=AF=BC?= Date: Wed, 22 Jul 2026 17:46:45 +0900 Subject: [PATCH] Add shipment, refund reason, and option availability to orders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - shipped 전환 시 운송사·송장번호(shipment) 필수 검증 및 shippedAt/deliveredAt 기록, POST /admin/order/shipment로 배송정보 정정(배송 시작 이력에 감사 정보 동기화) - refunded 전환 시 관리자 환불 사유(2~500자) 필수 — 주문 문서·상태 이력·포인트 원장(adminReason/adminActor)에 기록하고 DTO로 노출 - 상품 옵션에 active 필드 추가(구 문서는 활성 간주) — 비활성 옵션 주문은 OPTION_UNAVAILABLE 거부, 활성·교환 가능 상품은 활성 옵션 1개 이상 필요 - OrderDto/OrderStatusHistoryEntryDto/ProductOptionDto 확장 및 관련 테스트 보강 - 수정 파일 줄바꿈 LF 정규화 및 eslint --fix 적용 --- src/handlers/adminHandlers.ts | 52 ++++++-- src/services/orderService.ts | 135 +++++++++++++++++++- src/services/rewardCatalogService.ts | 14 +- src/types/dto/rewardDto.ts | 38 +++++- src/types/reward.ts | 25 +++- tests/services/orderService.test.ts | 98 +++++++++++++- tests/services/rewardCatalogService.test.ts | 8 +- tests/types/rewardDto.test.ts | 68 +++++++++- 8 files changed, 398 insertions(+), 40 deletions(-) diff --git a/src/handlers/adminHandlers.ts b/src/handlers/adminHandlers.ts index 59485be..71ed915 100644 --- a/src/handlers/adminHandlers.ts +++ b/src/handlers/adminHandlers.ts @@ -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)); diff --git a/src/services/orderService.ts b/src/services/orderService.ts index 2b54ca1..7aa4504 100644 --- a/src/services/orderService.ts +++ b/src/services/orderService.ts @@ -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, optionId: string | undefined): ProductOption | null { +export function resolveOrderOption( + product: Pick, + 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, 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(); 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(); 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 = { 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 = { + 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 }; diff --git a/src/services/rewardCatalogService.ts b/src/services/rewardCatalogService.ts index e1c3363..ee3ab87 100644 --- a/src/services/rewardCatalogService.ts +++ b/src/services/rewardCatalogService.ts @@ -43,12 +43,16 @@ export async function getCatalogProduct(id: string): Promise ({ id: o.id, name: o.name })), - } - : {}), + options: product.options.map((o) => ({ + id: o.id, + name: o.name, + active: o.active !== false, + })), + } : + {}), }; }); } diff --git a/src/types/dto/rewardDto.ts b/src/types/dto/rewardDto.ts index 744f143..23b7d66 100644 --- a/src/types/dto/rewardDto.ts +++ b/src/types/dto/rewardDto.ts @@ -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), }; } diff --git a/src/types/reward.ts b/src/types/reward.ts index a2a3db8..0376e0c 100644 --- a/src/types/reward.ts +++ b/src/types/reward.ts @@ -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 { diff --git a/tests/services/orderService.test.ts b/tests/services/orderService.test.ts index dba138f..8c54a45 100644 --- a/tests/services/orderService.test.ts +++ b/tests/services/orderService.test.ts @@ -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", }); }); }); diff --git a/tests/services/rewardCatalogService.test.ts b/tests/services/rewardCatalogService.test.ts index cc1f4cd..a7087da 100644 --- a/tests/services/rewardCatalogService.test.ts +++ b/tests/services/rewardCatalogService.test.ts @@ -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 }, ], }); }); diff --git a/tests/types/rewardDto.test.ts b/tests/types/rewardDto.test.ts index 4701ca6..d0183e4 100644 --- a/tests/types/rewardDto.test.ts +++ b/tests/types/rewardDto.test.ts @@ -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 키가 응답에서 사라진다", () => {