Extract ALREADY_EXISTS guard and drop duplicate wallet read

- isAlreadyExistsError를 pointService에서 middleware/errors로 이동 — 레포지토리가 서비스를 import하는 계층 역전 없이 양쪽에서 쓰기 위함. pointService는 re-export로 기존 호출부 호환 유지
- createOrder에서 getWalletTx 사전 호출과 잔액 사전 검사 제거 — applyPointChangesTx가 같은 트랜잭션에서 동일한 HttpError(409, INSUFFICIENT_BALANCE)를 던지므로 동작은 같고 wallet 문서 tx.get이 2회에서 1회로 준다
This commit is contained in:
윤정민 2026-07-27 13:24:35 +09:00
parent a9634b7229
commit aa53a5b070
3 changed files with 16 additions and 5 deletions

View File

@ -11,6 +11,16 @@ export class HttpError extends Error {
}
}
/**
* Firestore ALREADY_EXISTS `create()`
* "이미 존재" . ·
* .
*/
export function isAlreadyExistsError(err: unknown): boolean {
const code = (err as { code?: unknown })?.code;
return code === 6 || code === "already-exists" || code === "ALREADY_EXISTS";
}
export function sendError(res: express.Response, err: unknown): void {
if (err instanceof HttpError) {
const body: Record<string, unknown> = err.code

View File

@ -4,7 +4,6 @@ import { HttpError } from "../middleware/errors";
import { getOrder, listOrders, orderRef } from "../repositories/orderRepository";
import { productRef } from "../repositories/productRepository";
import { applyPointChangesTx } from "./pointService";
import { getWalletTx } from "../repositories/walletRepository";
import { PointLedgerType } from "../types/points";
import {
ORDER_TRANSITIONS,
@ -72,14 +71,16 @@ export async function createOrder(uid: string, input: CreateOrderInput) {
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;
if (!p || !p.active || !p.redeemable) throw new HttpError(409, "product unavailable", "PRODUCT_UNAVAILABLE");
const option = resolveOrderOption(p, item.optionId);
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");
// 잔액 검사는 applyPointChangesTx가 담당한다(pointService.ts:24가 동일한
// HttpError(409, INSUFFICIENT_BALANCE)를 던진다). 같은 트랜잭션 안이라
// 사전 검사를 두면 동일한 wallet 문서를 두 번 tx.get 하는 것 외에 차이가 없다.
const total = orderItems.reduce((n, i) => n + i.pointPrice * i.qty, 0);
// 주문은 즉시 확정 — 홀드 없이 바로 차감하고, 되돌림은 어드민 환불로만 처리한다.
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 };

View File

@ -1,12 +1,12 @@
import { Timestamp, type Transaction } from "firebase-admin/firestore";
import { firestore } from "../firebase";
import { HttpError } from "../middleware/errors";
import { HttpError, isAlreadyExistsError } from "../middleware/errors";
import { createLedgerEntryTx } from "../repositories/pointLedgerRepository";
import { getWalletTx, walletDocRef } from "../repositories/walletRepository";
import { OP_BY_TYPE, PointLedgerType, type PointChange, type PointLedgerEntry, type WalletDoc } from "../types/points";
const TX_ID = /^[A-Za-z0-9:_-]{1,240}$/;
export function isAlreadyExistsError(err: unknown): boolean { const code = (err as { code?: unknown })?.code; return code === 6 || code === "already-exists" || code === "ALREADY_EXISTS"; }
export { isAlreadyExistsError };
export async function applyPointChangesTx(tx: Transaction, uid: string, changes: PointChange[]): Promise<WalletDoc | null> {
for (const c of changes) if (!TX_ID.test(c.txId) || !Number.isSafeInteger(c.amount) || c.amount <= 0) throw new HttpError(400, "invalid point change", "INVALID_POINT_CHANGE");
const existing = await getWalletTx(tx, uid);