mmday-firebase/src/services/pointService.ts
윤정민 85edc7c7e3 Simplify orders to immediate debit with single refund path
- 주문 생성 시 예약(홀드) 없이 즉시 차감하고 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건 추가 (즉시 차감·잔액 부족·권한·전이 검증·환불 반환)
2026-07-21 16:06:13 +09:00

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)); }