- 주문 생성 시 예약(홀드) 없이 즉시 차감하고 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건 추가 (즉시 차감·잔액 부족·권한·전이 검증·환불 반환)
34 lines
2.3 KiB
TypeScript
34 lines
2.3 KiB
TypeScript
import { Timestamp, type Transaction } from "firebase-admin/firestore";
|
|
import { firestore } from "../firebase";
|
|
import { HttpError } 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 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);
|
|
if (changes.length === 0) return existing;
|
|
const now = Timestamp.now();
|
|
const wallet: WalletDoc = existing ? { ...existing } : { availableBalance: 0, totalEarned: 0, totalSpent: 0, version: 0, createdAt: now, updatedAt: now };
|
|
for (const c of changes) {
|
|
const beforeA = wallet.availableBalance; const op = OP_BY_TYPE[c.type];
|
|
if (op === "credit") {
|
|
wallet.availableBalance += c.amount;
|
|
if (c.type === PointLedgerType.OrderRefund) wallet.totalSpent -= c.amount;
|
|
else wallet.totalEarned += c.amount;
|
|
}
|
|
if (op === "debit") { wallet.availableBalance -= c.amount; wallet.totalSpent += c.amount; }
|
|
if (wallet.availableBalance < 0) throw new HttpError(409, "insufficient balance", "INSUFFICIENT_BALANCE");
|
|
const entry: PointLedgerEntry = { ...c, uid, op, availableBefore: beforeA, availableAfter: wallet.availableBalance, createdAt: now };
|
|
createLedgerEntryTx(tx, uid, entry);
|
|
}
|
|
if (wallet.availableBalance !== wallet.totalEarned - wallet.totalSpent) throw new Error("wallet invariant violated");
|
|
wallet.version += 1; wallet.updatedAt = now;
|
|
tx.set(walletDocRef(uid), wallet);
|
|
return wallet;
|
|
}
|
|
export async function applyPointChanges(uid: string, changes: PointChange[]) { return firestore.runTransaction((tx) => applyPointChangesTx(tx, uid, changes)); }
|