/** * 주문 취소 처리기 * * 결제 취소는 외부 PG사 호출이라 실패할 수 있습니다. 그래서 재고 복원과 * 포인트 환급을 먼저 하지 않고, PG 취소가 확정된 뒤에 처리합니다. * 순서를 바꾸면 PG 취소가 실패했을 때 재고만 늘어나는 사고가 납니다. */ async function cancelOrder(orderId, reason) { const order = await db.orders.findById(orderId); if (!order) throw new NotFoundError(`주문을 찾을 수 없습니다: ${orderId}`); // 이미 취소된 주문에 다시 요청이 오는 경우가 실제로 있습니다. // 멱등하게 처리하지 않으면 PG사에 중복 취소가 나갑니다. if (order.status === 'CANCELLED') { logger.info('이미 취소된 주문입니다. 무시합니다.', { orderId }); return order; } if (order.status === 'SHIPPED') { throw new ConflictError('출고된 주문은 취소할 수 없습니다. 반품으로 처리하세요.'); } const refund = await pg.refund({ transactionId: order.paymentId, amount: order.totalAmount, reason, }); if (!refund.ok) { // PG 실패는 재시도 대상이 아닙니다. 사람이 봐야 합니다. await alerts.notify('결제 취소 실패', { orderId, code: refund.code }); throw new PaymentError(`결제 취소 실패 (${refund.code})`); } // 여기서부터는 되돌릴 수 없으므로 한 트랜잭션으로 묶습니다. return db.transaction(async (tx) => { await tx.orders.update(orderId, { status: 'CANCELLED', cancelledAt: new Date(), cancelReason: reason, }); // 재고 복원 — 부분 취소는 아직 지원하지 않습니다. for (const item of order.items) { await tx.inventory.increment(item.sku, item.quantity); } // 적립금은 사용분만 돌려줍니다. 적립분은 회수합니다. if (order.pointsUsed > 0) { await tx.points.refund(order.userId, order.pointsUsed); } if (order.pointsEarned > 0) { await tx.points.revoke(order.userId, order.pointsEarned); } return tx.orders.findById(orderId); }); }