import { ExcelService } from '@lib/excel/src';
import { SmartroService } from '@libs/smartro';
import {
  BadRequestException,
  ForbiddenException,
  Inject,
  Injectable,
  InternalServerErrorException,
  Logger,
  NotFoundException,
  forwardRef,
} from '@nestjs/common';
import dayjs from 'dayjs';
import { SQL, and, between, eq, gte, isNull, like, lte, or } from 'drizzle-orm';
import {
  thMoneyCorp,
  thMoneyWallet,
  tnAgency,
  tnMoneyChargeRequest,
  tnMoneyGrant,
  tnMoneyIssue,
  tnSalesAgency,
} from 'drizzle/schema';
import { Workbook } from 'exceljs';
import { Response } from 'express';
import { AlimtalkService } from '../alimtalk/alimtalk.service';
import {
  CancelChargeDto,
  CancelMoneyPaymentDto,
  CreateMoneyCorpDto,
  CreateMoneyChargeRequestDto,
  CreateMoneyGrantDto,
  CreateMoneyGrantSingleDto,
  CreateMoneyIssueDto,
  GetMoneyChargeRequestListDto,
  GetMemberWalletHistoryDto,
  GetMoneyCorpHistoryDto,
  GetMoneyIssueListDto,
  GetMoneyPaymentListDto,
  GetMoneyRefundListDto,
  GetMoneyWalletListDto,
  GetUserMoneyHistoryDto,
  MoneyCancelType,
  MoneyChargeRequestStatus,
  MoneyGrantType,
  MoneyIssueType,
  MoneyMemberType,
  MoneyMoneyType,
  MoneyPaymentStatus,
  MoneyRefundStatus,
  MoneySourceType,
  MoneyTargetType,
  MoneyUsageType,
  MoneyWalletType,
  ProcessMoneyRefundDto,
  ProcessMoneyChargeRequestDto,
  CancelMoneyChargeRequestDto,
  MAX_MONEY_CHANGE_AMOUNT,
  MAX_MONEY_BALANCE_AMOUNT,
  RequestChargeDto,
  RequestRefundDto,
} from './money.dto';
import { MoneyRepository } from './money.repository';
import type { DrizzleService } from '@lib/drizzle/src/drizzle.service';
import { PROCESSED_BY_OUT_OF_SCOPE_NOTICE } from '../audit-log/audit-log.vocab';
import { systemActor } from '../audit/audit-actor';
import { batchIds } from '../audit/audit-changes';
import {
  AuditReasonCode,
  CorpGrantReasonCode,
  assertInterventionReason,
  corpGrantReason,
} from '../audit/audit-reason';
import { AUDIT_ACTION, AuditAction, AuditContext } from '../audit/audit.entity';
import { AuditService, MoneyAuditEvent } from '../audit/audit.service';

type MoneyGrantScope = {
  agcId: number;
  orgPath?: string;
  actorType?: 'CORP_SUPER' | 'CORP_ORG';
  actorMemId?: number | null;
};

/**
 * 금전 표(`tn_money_grant`·`th_money_corp`)에 남는 행위자.
 *
 * **`SALES` 가 없어서 영업대리점 지급이 `ADMIN` 으로 오표기됐다.** sales JWT 에는 `admId` 가
 * 없어(`auth.service.ts:76`) 컨트롤러의 `admId ?? null` 이 NULL 이 되고, scope 도 corp 전용이라
 * `getMoneyActor` 의 기본값 `ADMIN` 으로 떨어졌다 — 신원이 사라질 뿐 아니라 **서비스 관리자
 * 행위로 읽혔다.**
 *
 * `actorMemId` 는 **회원 id 공간 전용**이다. sales 의 `sagId` 를 여기 넣지 않는다 —
 * 컬럼명이 `mng_actor_mem_id` 이고 인덱스도 그 이름으로 서 있어서, 넣으면 `tn_member` 와의
 * 조인이 우연히 일치하는 회원을 물어 **조용한 오조인**이 된다. sales 의 완전한 신원은
 * 감사 줄(`audit.actorRole`·`audit.actorRefId`)이 담는다.
 */
type MoneyActor = {
  actorType: 'ADMIN' | 'SALES' | 'CORP_SUPER' | 'CORP_ORG';
  actorMemId: number | null;
};

/**
 * 감사 줄에 실을 금전 행위 요약.
 *
 * 트랜잭션 **안**에서 채우고 **밖**에서 방출한다(계획 D3). 담는 자리는 언제나 메서드의 지역
 * 변수다 — `MoneyService` 는 싱글턴이라 인스턴스 필드에 담으면 동시 요청의 감사 줄이 섞인다.
 */
type MoneyAuditFields = {
  targetType: string;
  targetId: number | null;
  amount: number | null;
  targetCount: number;
  /**
   * 개입 사유 유형 — 선택 칸이 아니다(계획 M8 D2a). 새 적재 경로가 사유를 빠뜨리면 tsc 가
   * 거절한다. 값은 서비스 진입에서 `assertInterventionReason`(관리자) 또는 `corpGrantReason`
   * (기업·조직관리자 지급·회수 — 사람이 고르지 않는다)이 돌려준 것만 싣는다.
   */
  reasonCode: AuditReasonCode | CorpGrantReasonCode;
  changes: Record<string, unknown> | null;
  batch?: Record<string, unknown> | null;
};

/**
 * 행위자 형태를 못 알아본 요청의 거부 코드.
 *
 * `AllExceptionFilter` 가 `exception.getResponse()` 를 클라이언트에 그대로 되돌리므로 **고정
 * 불투명 문자열만** 싣는다. 내부 사정은 감사 줄(T2 `FAILED`)과 에러 로그에 남는다.
 */
const UNRESOLVED_MONEY_ACTOR = 'E_MONEY_ACTOR_UNRESOLVED';

const assertIntegerMoneyAmount = (amount: number, label: string) => {
  if (!Number.isInteger(amount) || amount === 0) {
    throw new BadRequestException(`${label}은 1원 이상 입력해주세요.`);
  }
  if (Math.abs(amount) > MAX_MONEY_CHANGE_AMOUNT) {
    throw new BadRequestException(
      `${label}은 2,147,483,647원 이하로 입력해주세요.`,
    );
  }
};

const assertPositiveMoneyAmount = (amount: number, label: string) => {
  if (!Number.isInteger(amount) || amount <= 0) {
    throw new BadRequestException(`${label}은 1원 이상 입력해주세요.`);
  }
  if (amount > MAX_MONEY_CHANGE_AMOUNT) {
    throw new BadRequestException(
      `${label}은 2,147,483,647원 이하로 입력해주세요.`,
    );
  }
};

const assertMoneyChangeTotal = (amount: number, label: string) => {
  if (amount > MAX_MONEY_CHANGE_AMOUNT) {
    throw new BadRequestException(
      `${label}은 2,147,483,647원 이하로 처리해주세요.`,
    );
  }
};

const assertMoneyBalance = (amount: number, label: string) => {
  if (
    !Number.isInteger(amount) ||
    amount < 0 ||
    amount > MAX_MONEY_BALANCE_AMOUNT
  ) {
    throw new BadRequestException(
      `${label}은 4,294,967,295원을 초과할 수 없습니다.`,
    );
  }
};

const NOT_CANCELLING_MESSAGE =
  '취소 처리 중인 결제가 아니거나 이미 처리되었습니다.';

/**
 * 결과 확인의 「취소 기록 없음」 을 믿기까지 기다리는 시간. 시간으로 미처리를 보장할 수는 없다 (PG 반영 지연 미확인).
 * 해제를 아예 막으면 건이 끝나지 않아 돈이 덜 위험한 쪽으로 길게 잡았다. 환불이 확인된 건은 기다리지 않는다.
 */
const CANCEL_RELEASE_MIN_SECONDS = 24 * 60 * 60;

type CancelResolveResult = 'REFUNDED_FULL' | 'REFUNDED_PARTIAL' | 'RELEASED';

const CANCEL_RESOLVE_MESSAGE: Record<CancelResolveResult, string> = {
  REFUNDED_FULL: '결제사에서 전액 환불을 확인해 취소를 반영했습니다.',
  REFUNDED_PARTIAL: '결제사에서 부분 환불을 확인해 취소를 반영했습니다.',
  RELEASED: '결제사에 취소 기록이 없어 결제완료로 되돌렸습니다.',
};

/** PG 환불액으로 판정한다. 결제액도 예약액도 아닌 금액은 판정하지 않는다 (null). */
function cancelResolveResult(
  pgRefund: number,
  paymentMoney: number,
  reserved: number,
): CancelResolveResult | null {
  if (pgRefund === 0) return 'RELEASED';
  if (pgRefund === paymentMoney) return 'REFUNDED_FULL';
  return pgRefund === reserved ? 'REFUNDED_PARTIAL' : null;
}

/**
 * 단조 시각으로 잰 경과(ms). `audit.txDurationMs` 의 **유일한** 계산 자리다(계획 M2 D4a).
 *
 * **`Date.now()` 가 아닌 이유**: 벽시계는 NTP 보정으로 뒤로 갈 수 있고, 그러면 150ms 걸린
 * 트랜잭션이 50ms 로 보고돼 "트랜잭션 경과는 락 보유시간의 상한" 이라는 D4 의 주장이 그
 * 줄에서 거짓이 된다. `process.hrtime.bigint()` 는 단조이고 ns 해상도라 역행과 ms 양자화를
 * 함께 닫는다. Node 내장이므로 신규 의존이 없다(INV1).
 *
 * **호출부마다 표현을 쓰지 않고 여기 한 곳에 모으는 이유**: 표현이 흩어지면 단위가 갈리고,
 * `Number(txStartedAt)`(ns 원시값)이나 `msSince(x) - 1000` 같은 형태가 섞여도 아무도 못
 * 본다. 게이트 `#5` 는 이 정의의 본문을 보고, 호출부에는 인자가 정확히 `msSince(<그 자리
 * 시각 변수>)` 인지만 요구한다.
 *
 * **정수 ms 로 반올림한다.** ns 차를 그대로 나누면 소수가 나오는데, 이 값의 소비자
 * (`audit-rate.mjs` 의 `txDurationMs` 분위수)는 모집단을 정수로 한정한다 — 소수를 내보내면
 * 모든 v=2 줄이 그 모집단 **밖**으로 빠져 축이 배포 뒤에도 영구히 `n=0` 이 되고,
 * `readiness` 가 `reason=axisEmpty` 로 M3 를 영원히 막는다. 락 보유시간의 **상한**을 재는
 * 데 ms 해상도면 충분하므로, 정밀도가 아니라 모집단 정의를 맞추는 쪽이 옳다.
 */
const msSince = (startedAt: bigint): number =>
  Math.round(Number(process.hrtime.bigint() - startedAt) / 1e6);

/**
 * 회원머니 잔액 전·후를 감사 한 행에 드는 값으로 접는다(계획 M8 D6).
 *
 * 기준은 기본머니(`memMoney`)다 — 발행이 바꾸는 잔액이 그것이고, 지갑 행 `mwl_balance` 는 세
 * 머니의 합계라 다르다. 대상이 한 명이면 그 회원의 전·후, 여럿이면 회원별 값의 합이다. 대상별
 * 값은 업무 표(대상 표·지갑 행)에 이미 있고 `changes` 는 1KB 라 목록을 싣지 않는다.
 *
 * **회원별로 먼저 접는다.** 같은 회원이 한 배치에 여러 번 나오면 두 번째 읽기가 첫 반영 뒤
 * 잔액이라, 쌍을 그대로 더하면 잔액 100 인 회원에게 +10·+20 이 `210→240` 으로 남는다 — 실제는
 * 한 명의 `100→130` 이다. 그래서 첫 전 잔액과 마지막 후 잔액을 쓴다.
 */
export function memberMoneyChanges(
  pairs: { memId: number; before: number; after: number }[],
): Record<string, number> {
  const members = new Map<number, { before: number; after: number }>();
  for (const p of pairs) {
    const seen = members.get(p.memId);
    members.set(p.memId, {
      before: seen ? seen.before : p.before,
      after: p.after,
    });
  }
  //* 대상이 없으면 **아무 칸도 싣지 않는다**(로컬 리뷰 8). 아래 합계 분기로 떨어지면 빈 배치가
  //*  `0 → 0` 이라는 관측을 만들고, 그것은 '움직이지 않았다' 로 읽힌다 — 실제로는 잴 대상이
  //*  없었다. 키의 부재가 정확한 관측이다.
  if (members.size === 0) return {};
  if (members.size === 1) {
    const [m] = members.values();
    return { memberMoneyBefore: m.before, memberMoneyAfter: m.after };
  }
  let before = 0;
  let after = 0;
  for (const m of members.values()) {
    before += m.before;
    after += m.after;
  }
  return { memberMoneySumBefore: before, memberMoneySumAfter: after };
}

const CANCELLING_SHORTFALL_MESSAGE =
  '취소 처리 중인 결제가 있어 그 금액은 쓸 수 없습니다.';

@Injectable()
export class MoneyService {
  private readonly logger = new Logger(MoneyService.name);

  constructor(
    private moneyRepository: MoneyRepository,
    private excelService: ExcelService,
    private smartroService: SmartroService,
    @Inject(forwardRef(() => AlimtalkService))
    private alimtalkService: AlimtalkService,
    //* T1 감사 원본. **지급 트랜잭션 안에서** append 하며 실패하면 그 트랜잭션이 함께 롤백된다
    //*  — 돈이 움직였는데 기록이 없는 상태를 만들지 않기 위한 규약이다(조사 §V1-T1).
    private audit: AuditService,
  ) {}

  // ============================================================================
  // 관리자 - 회원머니 관리
  // ============================================================================

  /**
   * 회원머니 적립 목록 조회
   */
  async getIssueList(dto: GetMoneyIssueListDto, canSeeProcessed = false) {
    const where = this.buildIssueWhere(dto);
    const page = dto.page ?? 1;
    const limit = dto.limit ?? 10;

    const [list, count] = await Promise.all([
      this.moneyRepository.getIssueList(where, page, limit, canSeeProcessed),
      this.moneyRepository.getIssueCount(where),
    ]);

    return { count, list };
  }

  private buildIssueWhere(dto: GetMoneyIssueListDto): SQL<unknown> | undefined {
    const conditions: SQL<unknown>[] = [];

    if (dto.startDate && dto.endDate) {
      conditions.push(
        between(
          tnMoneyIssue.mniCreatedAt,
          `${dto.startDate} 00:00:00`,
          `${dto.endDate} 23:59:59`,
        ),
      );
    }

    if (dto.targetType) {
      conditions.push(eq(tnMoneyIssue.mniTargetType, dto.targetType));
    }

    //* 기업 필터(corp 로그인 시 컨트롤러가 자기 기업으로 강제)
    if (dto.agcId) {
      conditions.push(eq(tnMoneyIssue.agcId, dto.agcId));
    }

    // searchKey + searchText 패턴 (CODE_STYLE_GUIDE 준수)
    if (dto.searchKey && dto.searchText) {
      const searchText = `%${dto.searchText}%`;
      switch (dto.searchKey) {
        case 'title':
          conditions.push(like(tnMoneyIssue.mniTitle, searchText));
          break;
        case 'reason':
          conditions.push(like(tnMoneyIssue.mniReason, searchText));
          break;
      }
    }

    return conditions.length > 0 ? and(...conditions) : undefined;
  }

  /**
   * 회원머니 적립 상세 조회
   */
  async getIssueDetail(mniId: number, page: number, limit: number = 10) {
    const issue = await this.moneyRepository.getIssueById(mniId);
    if (!issue) {
      throw new NotFoundException('적립 정보를 찾을 수 없습니다.');
    }

    const [list, count] = await Promise.all([
      this.moneyRepository.getIssueTargetList(mniId, page, limit),
      this.moneyRepository.getIssueTargetCount(mniId),
    ]);

    return { issue, count, list };
  }

  /**
   * 회원머니 적립/적립회수 일괄 처리
   */
  //* scopeAgcId: 기업관리자(corp) 지급 스코프 — 지정 시 자기 기업으로 강제 + 소속 외 회원 지급 차단
  async createIssue(
    admId: number | null,
    dto: CreateMoneyIssueDto,
    ctx: AuditContext,
    scopeAgcId?: number,
  ) {
    const reasonCode = assertInterventionReason(dto.reasonCode, [
      dto.mniReason,
    ]);
    if (scopeAgcId) {
      dto.agcId = scopeAgcId;
      dto.mniMemberType = MoneyMemberType.CORP;
    }
    // ========== 1단계: 사전 검증 (트랜잭션 외부) ==========
    const targetMembers = await this.getTargetMembers(dto);
    if (targetMembers.length === 0) {
      throw new BadRequestException('대상 회원이 없습니다.');
    }

    const amount = dto.mniAmount; // 양수=적립, 음수=회수
    assertIntegerMoneyAmount(amount, '적용머니');
    const totalCount = targetMembers.length;
    assertMoneyChangeTotal(totalCount * Math.abs(amount), '총 적용머니');
    const validationErrors: string[] = [];
    const validatedMembers: Array<{ memId: number; member: any }> = [];

    // agcId 결정 - 특정회원 선택 시 첫 번째 회원의 소속 기업 사용
    let agcId = dto.agcId || null;

    for (const { memId } of targetMembers) {
      const member = await this.moneyRepository.getMemberById(memId);
      if (!member) {
        validationErrors.push(`회원(ID: ${memId})을 찾을 수 없습니다.`);
        continue;
      }

      //* 기업관리자 지급 스코프: 자기 기업 소속 외 회원 지급/회수 불가 (SPECIFIC 임의 memId 차단)
      if (scopeAgcId && member.agcId !== scopeAgcId) {
        validationErrors.push(
          `회원(ID: ${memId})은 소속 기업 회원이 아닙니다.`,
        );
        continue;
      }

      // agcId 결정 (첫 번째 회원)
      if (
        !agcId &&
        dto.mniMemberType === MoneyMemberType.CORP &&
        dto.mniTargetType === MoneyTargetType.SPECIFIC &&
        validatedMembers.length === 0
      ) {
        if (member.agcId) {
          agcId = member.agcId;
        }
      }

      // 적립회수 시 잔액 확인
      if (amount < 0 && member.memMoney < Math.abs(amount)) {
        validationErrors.push(
          `회원(ID: ${memId})의 잔액이 부족합니다. (잔액: ${member.memMoney.toLocaleString()}원, 회수요청: ${Math.abs(amount).toLocaleString()}원)`,
        );
        continue;
      }

      validatedMembers.push({ memId, member });
    }

    // 검증 실패 시 즉시 에러 반환
    if (validationErrors.length > 0) {
      throw new BadRequestException(validationErrors.join('\n'));
    }

    // ========== 2단계: 트랜잭션으로 일괄 처리 ==========
    let pending: MoneyAuditFields | null = null;
    //* 시각은 트랜잭션 **밖**에서 잡는다(계획 M2 D4a). 안에서 재면 커밋 대기가 빠져
    //*  락 보유시간의 상한이 되지 못한다 — 상한이 아니면 M3 가 그 값으로 판단할 수 없다.
    const txStartedAt = process.hrtime.bigint();
    const result = await this.moneyRepository.runInTransaction(async (tx) => {
      // 적립 배치 생성
      const mniId = await this.moneyRepository.createIssue(
        {
          admId,
          agcId,
          mniType: dto.mniType,
          mniTargetType: dto.mniTargetType,
          mniMemberType: dto.mniMemberType,
          mniTitle: dto.mniTitle,
          mniReason: dto.mniReason || null,
          mniAmount: Math.abs(amount),
          mniMemberCount: totalCount,
          mniTotalAmount: totalCount * Math.abs(amount),
          mniHasExpiry: dto.mniHasExpiry || 0,
          mniExpiryDays: dto.mniExpiryDays || null,
        },
        tx,
      );

      // 각 회원에게 적립/회수 처리
      const balances: { memId: number; before: number; after: number }[] = [];
      for (const { memId } of validatedMembers) {
        if (amount > 0) {
          balances.push({
            memId,
            ...(await this.earnMoney(
              memId,
              mniId!,
              amount,
              dto.mniTitle,
              dto.mniHasExpiry,
              dto.mniExpiryDays,
              tx,
            )),
          });
        } else {
          balances.push({
            memId,
            ...(await this.recoverEarnMoney(
              memId,
              Math.abs(amount),
              dto.mniTitle,
              tx,
            )),
          });
        }

        // 적립 대상 기록
        await this.moneyRepository.createIssueTarget(
          {
            mniId: mniId!,
            memId,
            mitAmount: Math.abs(amount),
          },
          tx,
        );
      }

      pending = {
        targetType: 'agency',
        targetId: agcId ?? null,
        amount: totalCount * amount,
        targetCount: totalCount,
        reasonCode,
        changes: {
          mniId,
          unitAmount: amount,
          mniType: dto.mniType,
          mniTargetType: dto.mniTargetType,
          mniMemberType: dto.mniMemberType,
          hasExpiry: dto.mniHasExpiry ?? 0,
          expiryDays: dto.mniExpiryDays ?? null,
          ...memberMoneyChanges(balances),
        },
      };
      await this.appendMoneyAudit(tx, ctx, AUDIT_ACTION.moneyIssue, pending);

      return {
        success: true,
        mniId,
        totalCount,
        successCount: totalCount,
        failedCount: 0,
      };
    });
    this.recordMoneyAudit(
      ctx,
      AUDIT_ACTION.moneyIssue,
      pending,
      msSince(txStartedAt),
    );
    return result;
  }

  /**
   * 대상 회원 조회
   */
  private async getTargetMembers(
    dto: CreateMoneyIssueDto | CreateMoneyGrantDto,
    orgPath?: string,
  ) {
    const targetType =
      'mniTargetType' in dto ? dto.mniTargetType : dto.mngTargetType;

    if (targetType === MoneyTargetType.SPECIFIC) {
      const memberIds = 'memberIds' in dto ? dto.memberIds : [];
      return [...(memberIds || [])]
        .map((memId) => Number(memId))
        .sort((a, b) => a - b)
        .map((memId) => ({ memId }));
    }

    const agcId = 'agcId' in dto ? dto.agcId : undefined;
    const isSubscribed = targetType === MoneyTargetType.SUBSCRIPTION;

    if (agcId) {
      return await this.moneyRepository.getMembersByAgcId(
        agcId,
        isSubscribed,
        orgPath,
      );
    }

    return await this.moneyRepository.getAllMembers(isSubscribed);
  }

  /**
   * 머니 적립 (EARN)
   */
  private async earnMoney(
    memId: number,
    mniId: number,
    amount: number,
    title: string,
    hasExpiry?: number,
    expiryDays?: number,
    tx?: any,
  ) {
    const member = await this.moneyRepository.getMemberByIdForUpdate(memId, tx);
    if (!member) {
      throw new NotFoundException('회원 정보를 찾을 수 없습니다.');
    }

    const newBalance =
      member.memMoney +
      member.memPurchaseMoney +
      member.memAgencyMoney +
      amount;
    assertMoneyBalance(member.memMoney + amount, '회원 기본머니 잔액');
    assertMoneyBalance(newBalance, '회원 전체 머니 잔액');

    // 1. wallet 이력 생성
    const mwlId = await this.moneyRepository.createWallet(
      {
        memId,
        mwlType: MoneyWalletType.EARN,
        mwlAmount: amount,
        mwlBalance: newBalance,
        mwlDescription: title,
      },
      tx,
    );

    // 2. source 생성 (FIFO 추적)
    const expiredAt =
      hasExpiry && expiryDays
        ? dayjs().add(expiryDays, 'day').format('YYYY-MM-DD 23:59:59')
        : null;

    await this.moneyRepository.createSource(
      {
        memId,
        mwlId: mwlId!,
        msrType: MoneySourceType.EARN,
        msrMoneyType: MoneyMoneyType.BASIC,
        msrOriginalAmount: amount,
        msrRemainingAmount: amount,
        mniId,
        mngId: null,
        mpyId: null,
        msrExpiredAt: expiredAt,
      },
      tx,
    );

    // 3. member 잔액 업데이트
    await this.moneyRepository.updateMemberMoney(
      memId,
      member.memMoney + amount,
      member.memPurchaseMoney,
      member.memAgencyMoney,
      tx,
    );
    return { before: member.memMoney, after: member.memMoney + amount };
  }

  /**
   * 시스템 적립 (추천인 혜택 등)
   * - mniId 없이 적립 가능
   * - EARN 타입으로 wallet/source 생성
   */
  async earnMoneyBySystem(
    memId: number,
    amount: number,
    title: string,
    hasExpiry: boolean = true,
    expiryDays?: number,
  ) {
    //* 외부 트랜잭션 안에서 부르지 않는다 (호출자 확인, 2026-10-22). 회원 잠금이 트랜잭션의 첫 질의다.
    return await this.moneyRepository.runInTransaction(async (tx) => {
      const member = await this.moneyRepository.getMemberByIdForUpdate(
        memId,
        tx,
      );
      if (!member) {
        throw new NotFoundException('회원 정보를 찾을 수 없습니다.');
      }

      const newBalance =
        member.memMoney +
        member.memPurchaseMoney +
        member.memAgencyMoney +
        amount;
      assertMoneyBalance(member.memMoney + amount, '회원 기본머니 잔액');
      assertMoneyBalance(newBalance, '회원 전체 머니 잔액');

      // 1. wallet 이력 생성
      const mwlId = await this.moneyRepository.createWallet(
        {
          memId,
          mwlType: MoneyWalletType.EARN,
          mwlAmount: amount,
          mwlBalance: newBalance,
          mwlDescription: title,
        },
        tx,
      );

      // 2. source 생성 (FIFO 추적)
      const expiredAt =
        hasExpiry && expiryDays
          ? dayjs().add(expiryDays, 'day').format('YYYY-MM-DD 23:59:59')
          : null;

      await this.moneyRepository.createSource(
        {
          memId,
          mwlId: mwlId!,
          msrType: MoneySourceType.EARN,
          msrMoneyType: MoneyMoneyType.BASIC,
          msrOriginalAmount: amount,
          msrRemainingAmount: amount,
          mniId: null, // 시스템 적립은 mniId 없음
          mngId: null,
          mpyId: null,
          msrExpiredAt: expiredAt,
        },
        tx,
      );

      // 3. member 잔액 업데이트
      await this.moneyRepository.updateMemberMoney(
        memId,
        member.memMoney + amount,
        member.memPurchaseMoney,
        member.memAgencyMoney,
        tx,
      );

      return { mwlId, amount, newBalance };
    });
  }

  /**
   * 적립 회수 (EARN_RECOVERY)
   */
  private async recoverEarnMoney(
    memId: number,
    amount: number,
    title: string,
    tx?: any,
  ) {
    const member = await this.moneyRepository.getMemberByIdForUpdate(memId, tx);
    if (!member) {
      throw new NotFoundException('회원 정보를 찾을 수 없습니다.');
    }

    if (member.memMoney < amount) {
      throw new BadRequestException('회수할 머니가 부족합니다.');
    }

    const newBalance =
      member.memMoney +
      member.memPurchaseMoney +
      member.memAgencyMoney -
      amount;
    assertMoneyBalance(member.memMoney - amount, '회원 기본머니 잔액');
    assertMoneyBalance(newBalance, '회원 전체 머니 잔액');

    // 1. FIFO 차감
    const usages = await this.deductFromSources(
      memId,
      amount,
      MoneyMoneyType.BASIC,
      tx,
    );

    // 2. wallet 이력 생성
    const mwlId = await this.moneyRepository.createWallet(
      {
        memId,
        mwlType: MoneyWalletType.EARN_RECOVERY,
        mwlAmount: -amount,
        mwlBalance: newBalance,
        mwlDescription: title,
      },
      tx,
    );

    // 3. usage 기록
    for (const usage of usages) {
      await this.moneyRepository.createUsage(
        {
          mwlId: mwlId!,
          msrId: usage.msrId,
          musType: MoneyUsageType.DEDUCT,
          musAmount: usage.amount,
        },
        tx,
      );
    }

    // 4. member 잔액 업데이트
    await this.moneyRepository.updateMemberMoney(
      memId,
      member.memMoney - amount,
      member.memPurchaseMoney,
      member.memAgencyMoney,
      tx,
    );
    return { before: member.memMoney, after: member.memMoney - amount };
  }

  // ============================================================================
  // 관리자 - 기업머니 관리
  // ============================================================================

  /**
   * 기업머니 충전/차감 (관리자)
   */
  async createCorpMoney(
    admId: number | null,
    dto: CreateMoneyCorpDto,
    ctx: AuditContext,
  ) {
    const reasonCode = assertInterventionReason(dto.reasonCode, [
      dto.mcpReason,
    ]);
    const amount = dto.mcpAmount;
    assertIntegerMoneyAmount(amount, '기업머니 금액');
    const mcpType = amount > 0 ? 'EARN' : 'EARN_RECOVERY';
    //* 행위자를 `ctx` 에서 정한다. 이 라우트는 클래스 가드가 `@Roles('admin','sales')` 라
    //*  **sales 가 실제로 도달하고**, 'ADMIN' 하드코딩은 그 충전·차감을 서비스 관리자 행위로
    //*  오표기했다. 형태를 못 알아보는 요청은 여기서 거부된다(계획 D7a).
    const actor = this.getMoneyActor(undefined, ctx);

    let pending: MoneyAuditFields | null = null;
    //* 시각은 트랜잭션 **밖**에서 잡는다(계획 M2 D4a). 안에서 재면 커밋 대기가 빠져
    //*  락 보유시간의 상한이 되지 못한다 — 상한이 아니면 M3 가 그 값으로 판단할 수 없다.
    const txStartedAt = process.hrtime.bigint();
    const result = await this.moneyRepository.runInTransaction(async (tx) => {
      const agency = await this.moneyRepository.getAgencyByIdForUpdate(
        dto.agcId,
        tx,
      );
      if (!agency) {
        throw new NotFoundException('기업 정보를 찾을 수 없습니다.');
      }

      const newBalance = agency.agcMoney + amount;

      if (newBalance < 0) {
        throw new BadRequestException('기업머니가 부족합니다.');
      }
      assertMoneyBalance(newBalance, '기업머니 잔액');

      // 1. 기업머니 이력 생성
      await this.moneyRepository.createCorpHistory(
        {
          agcId: dto.agcId,
          admId,
          mngId: null,
          mcpActorType: actor.actorType,
          mcpActorMemId: actor.actorMemId,
          mcpType,
          mcpTitle: dto.mcpTitle,
          mcpReason: dto.mcpReason || null,
          mcpAmount: amount,
          mcpBalance: newBalance,
        },
        tx,
      );

      // 2. agency 잔액 업데이트
      await this.moneyRepository.updateAgencyMoney(dto.agcId, newBalance, tx);

      pending = {
        targetType: 'agency',
        targetId: dto.agcId,
        amount,
        targetCount: 1,
        reasonCode,
        changes: {
          mcpType,
          balanceBefore: newBalance - amount,
          balanceAfter: newBalance,
          moneyActorType: actor.actorType,
        },
      };
      await this.appendMoneyAudit(
        tx,
        ctx,
        AUDIT_ACTION.moneyCorpCharge,
        pending,
      );

      return { success: true, newBalance };
    });
    this.recordMoneyAudit(
      ctx,
      AUDIT_ACTION.moneyCorpCharge,
      pending,
      msSince(txStartedAt),
    );
    return result;
  }

  /**
   * 기업머니 내역 조회
   */
  async getCorpHistory(
    agcId: number,
    dto: GetMoneyCorpHistoryDto,
    canSeeProcessed = false,
  ) {
    const where = this.buildCorpHistoryWhere(dto);
    const page = dto.page ?? 1;
    const limit = dto.limit ?? 10;

    const [list, count, summary] = await Promise.all([
      this.moneyRepository.getCorpHistoryList(
        agcId,
        where,
        page,
        limit,
        canSeeProcessed,
      ),
      this.moneyRepository.getCorpHistoryCount(agcId, where),
      this.moneyRepository.getCorpSummary(agcId),
    ]);

    return { count, list, summary };
  }

  private buildCorpHistoryWhere(
    dto: GetMoneyCorpHistoryDto,
  ): SQL<unknown> | undefined {
    const conditions: SQL<unknown>[] = [];

    if (dto.startDate && dto.endDate) {
      conditions.push(
        between(
          thMoneyCorp.mcpCreatedAt,
          `${dto.startDate} 00:00:00`,
          `${dto.endDate} 23:59:59`,
        ),
      );
    }

    if (dto.mcpType) {
      conditions.push(eq(thMoneyCorp.mcpType, dto.mcpType));
    }

    if (dto.targetType) {
      conditions.push(eq(tnMoneyGrant.mngTargetType, dto.targetType));
    }

    // 검색 조건 (SearchDto) - searchKey로 필드 분기
    if (dto.searchKey && dto.searchText) {
      const searchText = `%${dto.searchText}%`;
      switch (dto.searchKey) {
        case 'title':
          conditions.push(like(thMoneyCorp.mcpTitle, searchText));
          break;
        case 'reason':
          conditions.push(like(thMoneyCorp.mcpReason, searchText));
          break;
      }
    }

    return conditions.length > 0 ? and(...conditions) : undefined;
  }

  // ============================================================================
  // 기업머니 충전요청
  // ============================================================================

  async getChargeRequestList(
    dto: GetMoneyChargeRequestListDto,
    scopeAgcId?: number,
    canSeeProcessed = false,
  ) {
    if (scopeAgcId) {
      dto.agcId = scopeAgcId;
    }

    const where = this.buildChargeRequestWhere(dto);
    const page = dto.page ?? 1;
    const limit = dto.limit ?? 10;

    const [list, count] = await Promise.all([
      this.moneyRepository.getChargeRequestList(
        where,
        page,
        limit,
        canSeeProcessed,
      ),
      this.moneyRepository.getChargeRequestCount(where),
    ]);

    return { count, list };
  }

  async createChargeRequest(
    agcId: number,
    requestMemId: number | null,
    dto: CreateMoneyChargeRequestDto,
  ) {
    const agency = await this.moneyRepository.getAgencyById(agcId);
    if (!agency) {
      throw new NotFoundException('기업 정보를 찾을 수 없습니다.');
    }

    const amount = Number(dto.mcrAmount);
    if (!Number.isInteger(amount) || amount < 10000) {
      throw new BadRequestException(
        '충전요청 금액은 10,000원 이상이어야 합니다.',
      );
    }
    if (amount > MAX_MONEY_CHANGE_AMOUNT) {
      throw new BadRequestException(
        '충전요청 금액은 2,147,483,647원 이하로 입력해주세요.',
      );
    }

    const mcrId = await this.moneyRepository.createChargeRequest({
      agcId,
      requestMemId,
      approvedAdmId: null,
      mcrStatus: MoneyChargeRequestStatus.REQUESTED,
      mcrPaymentMethod: 'MANUAL',
      mcrTitle: dto.mcrTitle?.trim() || '기업머니 충전요청',
      mcrReason: dto.mcrReason?.trim() || null,
      mcrAmount: amount,
      mcrAdminMemo: null,
      mcrRejectedReason: null,
    });

    return {
      success: true,
      mcrId,
      message: '충전요청이 등록되었습니다.',
    };
  }

  async processChargeRequest(
    admId: number,
    dto: ProcessMoneyChargeRequestDto,
    ctx: AuditContext,
  ) {
    const request = await this.moneyRepository.getChargeRequestById(dto.mcrId);
    if (!request) {
      throw new NotFoundException('충전요청을 찾을 수 없습니다.');
    }
    if (request.mcrStatus !== MoneyChargeRequestStatus.REQUESTED) {
      throw new BadRequestException('대기 중인 충전요청만 처리할 수 있습니다.');
    }

    if (dto.action === 'REJECT') {
      const rejectedReason = dto.rejectedReason?.trim();
      if (!rejectedReason) {
        throw new BadRequestException('반려 사유를 입력해주세요.');
      }

      const processed = await this.moneyRepository.rejectChargeRequest(
        dto.mcrId,
        admId,
        dto.adminMemo?.trim() || null,
        rejectedReason,
        dayjs().format('YYYY-MM-DD HH:mm:ss'),
      );
      if (!processed) {
        throw new BadRequestException(
          '대기 중인 충전요청만 처리할 수 있습니다.',
        );
      }

      return {
        success: true,
        message: '충전요청이 반려되었습니다.',
      };
    }

    //* 사유 판정은 승인에만 있다 — 반려는 사유 텍스트 필수가 이미 있고 적재·트랜잭션화와 함께
    //*  붙는다(계획 M8 UI9). 그래서 판정·트랜잭션·적재 모두 위 반려 블록 밖이다.
    const reasonCode = assertInterventionReason(dto.reasonCode, [
      dto.adminMemo,
    ]);
    assertPositiveMoneyAmount(request.mcrAmount, '충전요청 금액');

    let pending: MoneyAuditFields | null = null;
    //* 시각은 트랜잭션 **밖**에서 잡는다(계획 M2 D4a). 안에서 재면 커밋 대기가 빠져
    //*  락 보유시간의 상한이 되지 못한다 — 상한이 아니면 M3 가 그 값으로 판단할 수 없다.
    const txStartedAt = process.hrtime.bigint();
    const result = await this.moneyRepository.runInTransaction(async (tx) => {
      const approved = await this.moneyRepository.approveChargeRequest(
        request,
        admId,
        dto.adminMemo?.trim() || null,
        dayjs().format('YYYY-MM-DD HH:mm:ss'),
        tx,
      );
      //* 선점 UPDATE 가 0행이면 롤백할 쓰기가 없다. 반환값으로 흘리면 빈 pending 이 적재되므로
      //*  콜백 안에서 던진다(계획 M8 D5).
      if (!approved.processed) {
        throw new BadRequestException(
          '대기 중인 충전요청만 처리할 수 있습니다.',
        );
      }

      pending = {
        targetType: 'agency',
        targetId: request.agcId,
        amount: request.mcrAmount,
        targetCount: 1,
        reasonCode,
        changes: {
          mcrId: request.mcrId,
          corpBalanceBefore: approved.balanceBefore,
          corpBalanceAfter: approved.newBalance,
        },
      };
      await this.appendMoneyAudit(
        tx,
        ctx,
        AUDIT_ACTION.moneyChargeRequestApprove,
        pending,
      );

      return {
        success: true,
        newBalance: approved.newBalance,
        message: '충전요청이 승인되어 기업머니가 적립되었습니다.',
      };
    });
    this.recordMoneyAudit(
      ctx,
      AUDIT_ACTION.moneyChargeRequestApprove,
      pending,
      msSince(txStartedAt),
    );
    return result;
  }

  async cancelChargeRequest(agcId: number, dto: CancelMoneyChargeRequestDto) {
    const request = await this.moneyRepository.getChargeRequestById(dto.mcrId);
    if (!request || request.agcId !== agcId) {
      throw new NotFoundException('충전요청을 찾을 수 없습니다.');
    }
    if (request.mcrStatus !== MoneyChargeRequestStatus.REQUESTED) {
      throw new BadRequestException('대기 중인 충전요청만 취소할 수 있습니다.');
    }

    const canceled = await this.moneyRepository.cancelChargeRequest(
      dto.mcrId,
      agcId,
      dayjs().format('YYYY-MM-DD HH:mm:ss'),
    );
    if (!canceled) {
      throw new BadRequestException('대기 중인 충전요청만 취소할 수 있습니다.');
    }

    return {
      success: true,
      message: '충전요청이 취소되었습니다.',
    };
  }

  private buildChargeRequestWhere(
    dto: GetMoneyChargeRequestListDto,
  ): SQL<unknown> | undefined {
    const conditions: SQL<unknown>[] = [];

    if (dto.agcId) {
      conditions.push(eq(tnMoneyChargeRequest.agcId, dto.agcId));
    }

    if (dto.status) {
      conditions.push(eq(tnMoneyChargeRequest.mcrStatus, dto.status));
    }

    if (dto.sagName) {
      if (dto.sagName === '-') {
        conditions.push(isNull(tnSalesAgency.sagName));
      } else {
        conditions.push(eq(tnSalesAgency.sagName, dto.sagName));
      }
    }

    if (dto.startDate && dto.endDate) {
      conditions.push(
        between(
          tnMoneyChargeRequest.mcrCreatedAt,
          `${dto.startDate} 00:00:00`,
          `${dto.endDate} 23:59:59`,
        ),
      );
    }

    if (dto.searchKey && dto.searchText) {
      const searchText = `%${dto.searchText}%`;
      switch (dto.searchKey) {
        case 'agcCorporateName':
          conditions.push(like(tnAgency.agcCorporateName, searchText));
          break;
        case 'agcCorporateCode':
          conditions.push(like(tnAgency.agcCorporateCode, searchText));
          break;
        case 'mcrTitle':
          conditions.push(like(tnMoneyChargeRequest.mcrTitle, searchText));
          break;
        case 'mcrReason':
          conditions.push(like(tnMoneyChargeRequest.mcrReason, searchText));
          break;
      }
    }

    return conditions.length > 0 ? and(...conditions) : undefined;
  }

  /**
   * 금전 표에 남길 행위자를 정한다.
   *
   * 판정 순서가 계약이다: corp scope → sales → admin. `ctx` 가 아예 없는 내부 호출은 예전처럼
   * `ADMIN` 으로 떨어지지만, 금전 여섯 경로는 `ctx` 를 **필수 인자**로 받으므로 그 경로에서는
   * 이 폴백에 도달하지 않는다.
   *
   * **형태를 못 알아본 요청은 거부한다**(계획 D7a). 폴백으로 `ADMIN` 을 쓰면 오표기가 그대로
   * 남고, `'UNKNOWN'` 을 금전 컬럼에 쓰면 `mng_actor_type`·`mcp_actor_type` 을 읽는 관리자 API
   * 응답까지 값 도메인이 넓어진다. 두 사이의 옳은 자리는 거부다 — 가드를 통과했는데 신원
   * 형태를 못 알아보는 요청은 돈을 움직여서는 안 되고, 그 시도는 T2 인터셉터가 `FAILED` 로
   * 남긴다. 금전 표의 값 도메인은 불변이다.
   *
   * 도달 가능성: 관리자 금전 라우트는 `@Roles('admin','sales')`(클래스) 또는 corp 를 열되
   * 컨트롤러가 언제나 scope 를 만들어 넘긴다. 그래서 이 거부는 정상 트래픽에서 도달하지 않는
   * fail-closed 그물이며, 어떤 인가 판정도 바꾸지 않는다.
   */
  private getMoneyActor(
    scope?: MoneyGrantScope,
    ctx?: AuditContext,
  ): MoneyActor {
    if (scope?.actorType) {
      return {
        actorType: scope.actorType,
        actorMemId: scope.actorMemId ?? null,
      };
    }

    //* sales 는 회원이 아니므로 actorMemId 를 채우지 않는다(위 타입 주석의 오조인 사유).
    if (ctx?.actor.role === 'SALES') {
      return { actorType: 'SALES', actorMemId: null };
    }

    if (!ctx || ctx.actor.role === 'ADMIN') {
      return { actorType: 'ADMIN', actorMemId: null };
    }

    throw new InternalServerErrorException(UNRESOLVED_MONEY_ACTOR);
  }

  /**
   * 금전 감사 줄 한 건. 여섯 금전 트랜잭션이 같은 형태로 부른다.
   *
   * **트랜잭션이 정상 반환한 뒤에만 부른다.** 이전 구현은 DB append 성공 뒤 트랜잭션 **안에서**
   * 방출했고, append 층을 걷어내는 순간 그 자리는 롤백된 지급에도 `COMMITTED` 를 남기는 자리가
   * 됐다. `catch`·`finally` 도 같은 결함이다 — 커밋 단계에서 거부돼도 `finally` 는 돈다.
   * 트랜잭션이 던지면 이 자리에 도달하지 않고, 그 요청은 T2 인터셉터가 `FAILED` 로 잡는다.
   *
   * **배치당 1줄이다.** 대상별 상세는 `tn_money_grant_target`·`tn_money_issue_target` 이 이미
   * 담는다. 대상당 N줄을 내면 일괄 지급 한 건이 로그를 N배로 민다.
   */
  private recordMoneyAudit(
    ctx: AuditContext,
    action: AuditAction,
    pending: MoneyAuditFields | null,
    txDurationMs: number,
  ): void {
    if (pending === null) return;
    this.audit.recordMoney({
      ...this.moneyAuditEvent(ctx, action, pending),
      txDurationMs,
    });
  }

  /**
   * 금전 감사 **내구 행** 한 건. 여섯 금전 트랜잭션이 콜백 안에서 `pending` 을 채운 바로 뒤에 부른다.
   *
   * **이 헬퍼도 호출부도 실패를 감싸지 않는다.** 적재가 던지면 콜백이 던지고 트랜잭션이 거부되어
   * 지급이 롤백되며, 반환 뒤의 `recordMoneyAudit` 에 도달하지 않는다 — 그래서 `store: 'db'` 인 T1
   * 줄 옆에는 언제나 커밋된 행이 있다. `try` 나 `.catch` 하나가 그 보장을 통째로 지운다.
   *
   * 경과는 `null` 이다. 행은 커밋 **전**에 쓰이므로 그 트랜잭션의 경과를 알 수 없고, 같은 요청의
   * T1 줄이 `requestId` 로 이어 준다.
   */
  private async appendMoneyAudit(
    tx: DrizzleService,
    ctx: AuditContext,
    action: AuditAction,
    fields: MoneyAuditFields,
  ): Promise<void> {
    await this.audit.appendMoney(tx, {
      ...this.moneyAuditEvent(ctx, action, fields),
      txDurationMs: null,
    });
  }

  /** 금전 감사 이벤트 조립. 내구 행과 stdout 줄이 같은 필드를 싣도록 한 곳에 둔다. */
  private moneyAuditEvent(
    ctx: AuditContext,
    action: AuditAction,
    fields: MoneyAuditFields,
  ): Omit<MoneyAuditEvent, 'txDurationMs'> {
    return {
      action,
      outcome: 'COMMITTED',
      actor: ctx.actor,
      targetType: fields.targetType,
      targetId: fields.targetId,
      amount: fields.amount,
      targetCount: fields.targetCount,
      changes: fields.changes,
      batch: fields.batch ?? null,
      reasonCode: fields.reasonCode,
      requestId: ctx.requestId,
      ip: ctx.ip,
      userAgent: ctx.userAgent,
    };
  }

  // ============================================================================
  // 관리자 - 기업회원머니 관리 (지급/지급회수)
  // ============================================================================

  /**
   * 기업회원 일괄 지급
   */
  async createGrant(
    admId: number | null,
    dto: CreateMoneyGrantDto,
    ctx: AuditContext,
    scope?: MoneyGrantScope,
  ) {
    //* 기업·조직관리자(scope)는 사유 유형을 고르지 않는다 — 방향(배분/회수)이 사유다(2026-10 요청).
    const reasonCode = scope
      ? corpGrantReason(
          dto.mngType !== MoneyGrantType.GRANT_RECOVERY,
          dto.mngType === MoneyGrantType.GRANT_RECOVERY,
        )
      : assertInterventionReason(dto.reasonCode, [dto.mngReason]);
    const actor = this.getMoneyActor(scope, ctx);
    if (scope) {
      dto.agcId = scope.agcId;
    }
    // ========== 1단계: 사전 검증 (트랜잭션 외부) ==========
    const agency = await this.moneyRepository.getAgencyById(dto.agcId);
    if (!agency) {
      throw new NotFoundException('기업 정보를 찾을 수 없습니다.');
    }

    // 대상 회원 조회
    const targetMembers = await this.getTargetMembers(
      dto as any,
      scope?.orgPath,
    );
    if (targetMembers.length === 0) {
      throw new BadRequestException('대상 회원이 없습니다.');
    }

    const isRecovery = dto.mngType === MoneyGrantType.GRANT_RECOVERY;
    const amount = dto.mngAmount;
    assertPositiveMoneyAmount(amount, '적용머니');
    const totalRequired = amount * targetMembers.length;
    assertMoneyChangeTotal(totalRequired, '총 적용머니');
    const validationErrors: string[] = [];
    const validatedMembers: Array<{ memId: number; member: any }> = [];

    // 지급: 기업머니가 부족한지 체크
    if (!isRecovery && agency.agcMoney < totalRequired) {
      throw new BadRequestException(
        `기업머니가 부족합니다. (잔액: ${agency.agcMoney.toLocaleString()}원, 필요: ${totalRequired.toLocaleString()}원)`,
      );
    }

    // 각 회원별 사전 검증
    for (const { memId } of targetMembers) {
      const member = await this.moneyRepository.getMemberById(memId);
      if (!member) {
        validationErrors.push(`회원(ID: ${memId})을 찾을 수 없습니다.`);
        continue;
      }

      const inScope = await this.moneyRepository.isMemberInAgencyScope(
        memId,
        dto.agcId,
        scope?.orgPath,
      );
      if (!inScope) {
        validationErrors.push(
          `회원(ID: ${memId})은 지급 가능한 기업/조직 범위에 포함되지 않습니다.`,
        );
        continue;
      }

      // 회수 시: 회원의 기업머니 잔액 확인
      if (isRecovery && member.memAgencyMoney < amount) {
        validationErrors.push(
          `회원(ID: ${memId})의 기업머니가 부족합니다. (잔액: ${member.memAgencyMoney.toLocaleString()}원, 회수요청: ${amount.toLocaleString()}원)`,
        );
        continue;
      }

      validatedMembers.push({ memId, member });
    }

    if (validationErrors.length > 0) {
      throw new BadRequestException(validationErrors.join('\n'));
    }

    // ========== 2단계: 트랜잭션으로 일괄 처리 ==========
    let pending: MoneyAuditFields | null = null;
    //* 시각은 트랜잭션 **밖**에서 잡는다(계획 M2 D4a). 안에서 재면 커밋 대기가 빠져
    //*  락 보유시간의 상한이 되지 못한다 — 상한이 아니면 M3 가 그 값으로 판단할 수 없다.
    const txStartedAt = process.hrtime.bigint();
    const result = await this.moneyRepository.runInTransaction(async (tx) => {
      const lockedAgency = await this.moneyRepository.getAgencyByIdForUpdate(
        dto.agcId,
        tx,
      );
      if (!lockedAgency) {
        throw new NotFoundException('기업 정보를 찾을 수 없습니다.');
      }
      if (!isRecovery && lockedAgency.agcMoney < totalRequired) {
        throw new BadRequestException(
          `기업머니가 부족합니다. (잔액: ${lockedAgency.agcMoney.toLocaleString()}원, 필요: ${totalRequired.toLocaleString()}원)`,
        );
      }

      // 지급 배치 생성
      const mngId = await this.moneyRepository.createGrant(
        {
          agcId: dto.agcId,
          admId,
          mngActorType: actor.actorType,
          mngActorMemId: actor.actorMemId,
          mngType: dto.mngType,
          mngTargetType: dto.mngTargetType,
          mngTitle: dto.mngTitle,
          mngReason: dto.mngReason || null,
          mngAmount: amount,
          mngMemberCount: 0,
          mngTotalAmount: 0,
          mngHasExpiry: isRecovery ? 0 : dto.mngHasExpiry || 0,
          mngExpiryDays: isRecovery ? null : dto.mngExpiryDays || null,
        },
        tx,
      );

      // 각 회원에게 지급/회수 처리
      const totalCount = validatedMembers.length;

      for (const { memId } of validatedMembers) {
        if (isRecovery) {
          // 회수 처리
          await this.recoverGrantMoney(
            memId,
            dto.agcId,
            mngId!,
            amount,
            dto.mngTitle,
            tx,
          );
        } else {
          // 지급 처리
          await this.grantMoney(
            memId,
            dto.agcId,
            mngId!,
            amount,
            dto.mngTitle,
            dto.mngHasExpiry,
            dto.mngExpiryDays,
            tx,
          );
        }

        await this.moneyRepository.createGrantTarget(
          {
            mngId: mngId!,
            memId,
            mgtAmount: amount,
          },
          tx,
        );
      }

      // 기업머니 변동 이력
      // 지급: 기업머니 차감 (-), 회수: 기업머니 증가 (+)
      const corpMoneyChange = isRecovery
        ? totalCount * amount // 회수: 기업으로 돌아옴
        : -(totalCount * amount); // 지급: 기업에서 나감

      const newCorpBalance = lockedAgency.agcMoney + corpMoneyChange;
      assertMoneyBalance(newCorpBalance, '기업머니 잔액');

      await this.moneyRepository.createCorpHistory(
        {
          agcId: dto.agcId,
          admId,
          mngId: mngId!,
          mcpActorType: actor.actorType,
          mcpActorMemId: actor.actorMemId,
          mcpType: isRecovery ? 'GRANT_RECOVERY' : 'GRANT',
          mcpTitle: dto.mngTitle,
          mcpReason: dto.mngReason || null,
          mcpAmount: corpMoneyChange,
          mcpBalance: newCorpBalance,
        },
        tx,
      );

      await this.moneyRepository.updateAgencyMoney(
        dto.agcId,
        newCorpBalance,
        tx,
      );
      await this.moneyRepository.updateGrantCounts(
        mngId!,
        totalCount,
        totalCount * amount,
        tx,
      );

      pending = {
        targetType: 'agency',
        targetId: dto.agcId,
        amount: corpMoneyChange,
        targetCount: totalCount,
        reasonCode,
        changes: {
          mngId,
          unitAmount: amount,
          isRecovery,
          mngTargetType: dto.mngTargetType,
          corpBalanceBefore: lockedAgency.agcMoney,
          corpBalanceAfter: newCorpBalance,
          moneyActorType: actor.actorType,
        },
      };
      await this.appendMoneyAudit(tx, ctx, AUDIT_ACTION.moneyGrant, pending);

      return {
        success: true,
        mngId,
        processedCount: totalCount,
        message: isRecovery
          ? '머니 회수가 완료되었습니다.'
          : '머니 지급이 완료되었습니다.',
      };
    });
    this.recordMoneyAudit(
      ctx,
      AUDIT_ACTION.moneyGrant,
      pending,
      msSince(txStartedAt),
    );
    return result;
  }

  /**
   * 기업회원 개별 지급/회수
   */
  async createGrantSingle(
    admId: number | null,
    dto: CreateMoneyGrantSingleDto,
    ctx: AuditContext,
    scope?: MoneyGrantScope,
  ) {
    //* 기업·조직관리자(scope)는 사유 유형을 고르지 않는다 — 금액 부호(지급 +/회수 −)가 사유다.
    const reasonCode = scope
      ? corpGrantReason(dto.mngAmount > 0, dto.mngAmount < 0)
      : assertInterventionReason(dto.reasonCode, [dto.mngReason]);
    const actor = this.getMoneyActor(scope, ctx);
    if (scope) {
      dto.agcId = scope.agcId;
    }
    const agency = await this.moneyRepository.getAgencyById(dto.agcId);
    if (!agency) {
      throw new NotFoundException('기업 정보를 찾을 수 없습니다.');
    }

    const member = await this.moneyRepository.getMemberById(dto.memId);
    if (!member) {
      throw new NotFoundException('회원 정보를 찾을 수 없습니다.');
    }
    const inScope = await this.moneyRepository.isMemberInAgencyScope(
      dto.memId,
      dto.agcId,
      scope?.orgPath,
    );
    if (!inScope) {
      throw new ForbiddenException(
        '지급 가능한 기업/조직 범위의 회원이 아닙니다.',
      );
    }

    const amount = dto.mngAmount;
    assertIntegerMoneyAmount(amount, '적용머니');
    const isGrant = amount > 0;
    const hasExpiry = isGrant && dto.mngHasExpiry ? 1 : 0;
    const expiryDays = hasExpiry ? dto.mngExpiryDays : undefined;

    if (!isGrant && dto.mngExpiryDays && dto.mngExpiryDays > 0) {
      throw new BadRequestException('회수 시 유효기간을 설정할 수 없습니다.');
    }
    if (hasExpiry && (!expiryDays || expiryDays <= 0)) {
      throw new BadRequestException('유효기간은 1일 이상 입력해주세요.');
    }

    if (isGrant && agency.agcMoney < amount) {
      throw new BadRequestException('기업머니가 부족합니다.');
    }

    if (!isGrant && member.memAgencyMoney < Math.abs(amount)) {
      throw new BadRequestException('회원의 기업머니가 부족합니다.');
    }

    let pending: MoneyAuditFields | null = null;
    //* 시각은 트랜잭션 **밖**에서 잡는다(계획 M2 D4a). 안에서 재면 커밋 대기가 빠져
    //*  락 보유시간의 상한이 되지 못한다 — 상한이 아니면 M3 가 그 값으로 판단할 수 없다.
    const txStartedAt = process.hrtime.bigint();
    const result = await this.moneyRepository.runInTransaction(async (tx) => {
      const lockedAgency = await this.moneyRepository.getAgencyByIdForUpdate(
        dto.agcId,
        tx,
      );
      if (!lockedAgency) {
        throw new NotFoundException('기업 정보를 찾을 수 없습니다.');
      }
      if (isGrant && lockedAgency.agcMoney < Math.abs(amount)) {
        throw new BadRequestException('기업머니가 부족합니다.');
      }

      // 지급 배치 생성
      const mngId = await this.moneyRepository.createGrant(
        {
          agcId: dto.agcId,
          admId,
          mngActorType: actor.actorType,
          mngActorMemId: actor.actorMemId,
          mngType: isGrant
            ? MoneyGrantType.GRANT
            : MoneyGrantType.GRANT_RECOVERY,
          mngTargetType: MoneyTargetType.SPECIFIC,
          mngTitle: dto.mngTitle,
          mngReason: dto.mngReason || null,
          mngAmount: Math.abs(amount),
          mngMemberCount: 1,
          mngTotalAmount: Math.abs(amount),
          mngHasExpiry: hasExpiry,
          mngExpiryDays: hasExpiry ? expiryDays! : null,
        },
        tx,
      );

      if (isGrant) {
        await this.grantMoney(
          dto.memId,
          dto.agcId,
          mngId!,
          amount,
          dto.mngTitle,
          hasExpiry,
          expiryDays,
          tx,
        );
      } else {
        await this.recoverGrantMoney(
          dto.memId,
          dto.agcId,
          mngId!,
          Math.abs(amount),
          dto.mngTitle,
          tx,
        );
      }

      await this.moneyRepository.createGrantTarget(
        {
          mngId: mngId!,
          memId: dto.memId,
          mgtAmount: Math.abs(amount),
        },
        tx,
      );

      // 기업머니 변동 이력
      const corpChange = isGrant ? -Math.abs(amount) : Math.abs(amount);
      const newCorpBalance = lockedAgency.agcMoney + corpChange;
      assertMoneyBalance(newCorpBalance, '기업머니 잔액');

      await this.moneyRepository.createCorpHistory(
        {
          agcId: dto.agcId,
          admId,
          mngId: mngId!,
          mcpActorType: actor.actorType,
          mcpActorMemId: actor.actorMemId,
          mcpType: isGrant ? 'GRANT' : 'GRANT_RECOVERY',
          mcpTitle: dto.mngTitle,
          mcpReason: dto.mngReason || null,
          mcpAmount: corpChange,
          mcpBalance: newCorpBalance,
        },
        tx,
      );

      await this.moneyRepository.updateAgencyMoney(
        dto.agcId,
        newCorpBalance,
        tx,
      );

      pending = {
        targetType: 'member',
        targetId: dto.memId,
        amount: corpChange,
        targetCount: 1,
        reasonCode,
        changes: {
          mngId,
          agcId: dto.agcId,
          isGrant,
          hasExpiry,
          expiryDays: hasExpiry ? expiryDays : null,
          corpBalanceBefore: lockedAgency.agcMoney,
          corpBalanceAfter: newCorpBalance,
          moneyActorType: actor.actorType,
        },
      };
      await this.appendMoneyAudit(
        tx,
        ctx,
        AUDIT_ACTION.moneyGrantSingle,
        pending,
      );

      return { success: true, mngId };
    });
    this.recordMoneyAudit(
      ctx,
      AUDIT_ACTION.moneyGrantSingle,
      pending,
      msSince(txStartedAt),
    );
    return result;
  }

  /**
   * 기업머니 지급 (GRANT)
   */
  private async grantMoney(
    memId: number,
    agcId: number,
    mngId: number,
    amount: number,
    title: string,
    hasExpiry?: number,
    expiryDays?: number,
    tx?: any,
  ) {
    const member = await this.moneyRepository.getMemberByIdForUpdate(memId, tx);
    if (!member) {
      throw new NotFoundException('회원 정보를 찾을 수 없습니다.');
    }

    const newBalance =
      member.memMoney +
      member.memPurchaseMoney +
      member.memAgencyMoney +
      amount;
    assertMoneyBalance(member.memAgencyMoney + amount, '회원 기업머니 잔액');
    assertMoneyBalance(newBalance, '회원 전체 머니 잔액');

    // 1. wallet 이력 생성
    const mwlId = await this.moneyRepository.createWallet(
      {
        memId,
        mwlType: MoneyWalletType.GRANT,
        mwlAmount: amount,
        mwlBalance: newBalance,
        mwlDescription: title,
      },
      tx,
    );

    // 2. source 생성
    const expiredAt =
      hasExpiry && expiryDays
        ? dayjs().add(expiryDays, 'day').format('YYYY-MM-DD 23:59:59')
        : null;

    await this.moneyRepository.createSource(
      {
        memId,
        mwlId: mwlId!,
        msrType: MoneySourceType.GRANT,
        msrMoneyType: MoneyMoneyType.AGENCY,
        msrOriginalAmount: amount,
        msrRemainingAmount: amount,
        mniId: null,
        mngId,
        mpyId: null,
        msrExpiredAt: expiredAt,
      },
      tx,
    );

    // 3. member 잔액 업데이트
    await this.moneyRepository.updateMemberMoney(
      memId,
      member.memMoney,
      member.memPurchaseMoney,
      member.memAgencyMoney + amount,
      tx,
    );
  }

  /**
   * 지급 회수 (GRANT_RECOVERY)
   */
  private async recoverGrantMoney(
    memId: number,
    agcId: number,
    mngId: number,
    amount: number,
    title: string,
    tx?: any,
  ) {
    const member = await this.moneyRepository.getMemberByIdForUpdate(memId, tx);
    if (!member) {
      throw new NotFoundException('회원 정보를 찾을 수 없습니다.');
    }

    if (member.memAgencyMoney < amount) {
      throw new BadRequestException('회수할 기업머니가 부족합니다.');
    }

    const newBalance =
      member.memMoney +
      member.memPurchaseMoney +
      member.memAgencyMoney -
      amount;
    assertMoneyBalance(member.memAgencyMoney - amount, '회원 기업머니 잔액');
    assertMoneyBalance(newBalance, '회원 전체 머니 잔액');

    // 1. FIFO 차감 (AGENCY 타입만)
    const usages = await this.deductFromSources(
      memId,
      amount,
      MoneyMoneyType.AGENCY,
      tx,
    );

    // 2. wallet 이력 생성
    const mwlId = await this.moneyRepository.createWallet(
      {
        memId,
        mwlType: MoneyWalletType.GRANT_RECOVERY,
        mwlAmount: -amount,
        mwlBalance: newBalance,
        mwlDescription: title,
      },
      tx,
    );

    // 3. usage 기록
    for (const usage of usages) {
      await this.moneyRepository.createUsage(
        {
          mwlId: mwlId!,
          msrId: usage.msrId,
          musType: MoneyUsageType.DEDUCT,
          musAmount: usage.amount,
        },
        tx,
      );
    }

    // 4. member 잔액 업데이트
    await this.moneyRepository.updateMemberMoney(
      memId,
      member.memMoney,
      member.memPurchaseMoney,
      member.memAgencyMoney - amount,
      tx,
    );
  }

  // ============================================================================
  // 관리자 - 내역관리
  // ============================================================================

  /**
   * 전체 내역 조회 (관리자)
   */
  async getWalletList(dto: GetMoneyWalletListDto) {
    const page = dto.page ?? 1;
    const limit = dto.limit ?? 10;

    const [list, count] = await Promise.all([
      this.moneyRepository.getWalletListWithSearch(dto, page, limit),
      this.moneyRepository.getWalletCountWithSearch(dto),
    ]);

    //* 이 화면만 처리 계정을 안 보인다 — 그 경계를 서버가 말한다(계획 M7 D10a). 문구의 정본은
    //*  `audit-log.vocab.ts` 다: 화면에 박으면 범위가 바뀔 때 화면만 옛말을 한다.
    return { count, list, processedByNotice: PROCESSED_BY_OUT_OF_SCOPE_NOTICE };
  }

  /**
   * 머니 내역 엑셀 다운로드
   */
  async downloadWalletExcel(dto: GetMoneyWalletListDto, res: Response) {
    // 전체 데이터 조회 (page=0 으로 전체 조회)
    const list = await this.moneyRepository.getWalletListWithSearch(dto, 0, 0);

    const workbook = new Workbook();
    const worksheet = workbook.addWorksheet('머니내역');

    // 구분 타입 라벨
    const getTypeLabel = (type: string) => {
      switch (type) {
        case 'EARN':
          return '적립';
        case 'EARN_RECOVERY':
          return '적립회수';
        case 'GRANT':
          return '지급';
        case 'GRANT_RECOVERY':
          return '지급회수';
        case 'CHARGE':
          return '충전';
        case 'DEDUCT':
          return '차감';
        case 'USE':
          return '사용';
        case 'EXPIRE':
          return '소멸';
        default:
          return type || '-';
      }
    };

    // 회원타입 라벨
    const getMemTypeLabel = (type: string) => {
      if (type === 'GENERAL' || type === 'MEM001') return '일반';
      if (type === 'CORP' || type === 'MEM002') return '기업';
      if (type === 'FC' || type === 'MEM003') return 'FC';
      return type || '-';
    };

    worksheet.columns = [
      { header: '영업대리점명', key: 'sagName', width: 20 },
      { header: '영업채널명', key: 'sacName', width: 20 },
      { header: '회원타입', key: 'memType', width: 10 },
      { header: '기업명', key: 'agcCorporateName', width: 20 },
      { header: '구분', key: 'mwlType', width: 10 },
      { header: '회원아이디', key: 'memLoginId', width: 15 },
      { header: '회원명', key: 'memName', width: 15 },
      { header: '머니명', key: 'mwlDescription', width: 20 },
      { header: '사유', key: 'mwlReason', width: 30 },
      { header: '변경머니', key: 'mwlAmount', width: 15 },
      { header: '잔액머니', key: 'mwlBalance', width: 15 },
      { header: '유효기간', key: 'msrExpiredAt', width: 15 },
      { header: '등록일', key: 'mwlCreatedAt', width: 20 },
    ];

    list.forEach((item: any) => {
      worksheet.addRow({
        sagName: item.sagName || '미소속',
        sacName: item.sacName || '-',
        memType: getMemTypeLabel(item.memType),
        agcCorporateName: item.agcCorporateName || '-',
        mwlType: getTypeLabel(item.mwlType),
        memLoginId: item.memLoginId || '-',
        memName: item.memName || '-',
        mwlDescription: item.mwlDescription || '-',
        mwlReason: item.mwlReason || '-',
        mwlAmount: item.mwlAmount || 0,
        mwlBalance: item.mwlBalance || 0,
        msrExpiredAt: item.msrExpiredAt
          ? item.msrExpiredAt.substring(0, 10)
          : '-',
        mwlCreatedAt: item.mwlCreatedAt
          ? item.mwlCreatedAt.substring(0, 16).replace('T', ' ')
          : '-',
      });
    });

    await workbook.xlsx.write(res);
  }

  /**
   * 회원별 내역 조회 (관리자)
   */
  async getMemberWalletHistory(memId: number, dto: GetMemberWalletHistoryDto) {
    const member = await this.moneyRepository.getMemberById(memId);
    if (!member) {
      throw new NotFoundException('회원 정보를 찾을 수 없습니다.');
    }

    const page = dto.page ?? 1;
    const limit = dto.limit ?? 10;

    // 필터 조건 생성
    const conditions: SQL<unknown>[] = [];

    if (dto.mwlType) {
      conditions.push(eq(thMoneyWallet.mwlType, dto.mwlType));
    }
    if (dto.startDate && dto.endDate) {
      conditions.push(
        gte(thMoneyWallet.mwlCreatedAt, `${dto.startDate} 00:00:00`),
      );
      conditions.push(
        lte(thMoneyWallet.mwlCreatedAt, `${dto.endDate} 23:59:59`),
      );
    }
    if (dto.searchKey && dto.searchText) {
      const searchText = `%${dto.searchText}%`;
      switch (dto.searchKey) {
        case 'mwlDescription':
          conditions.push(like(thMoneyWallet.mwlDescription, searchText));
          break;
        case 'mwlReason':
          conditions.push(like(tnMoneyGrant.mngReason, searchText));
          break;
      }
    }

    const where = conditions.length > 0 ? and(...conditions) : undefined;

    const [list, count] = await Promise.all([
      this.moneyRepository.getWalletListByMemId(memId, where, page, limit),
      this.moneyRepository.getWalletCountByMemId(memId, where),
    ]);

    // 요약 정보 계산 (전체 데이터 기반)
    const allList = await this.moneyRepository.getWalletListByMemId(
      memId,
      undefined,
      0,
      10000,
    );

    const summary = {
      totalEarn: 0,
      totalCharge: 0,
      totalGrant: 0,
      totalExpire: 0,
      totalUse: 0,
      totalGrantRecovery: 0,
      totalEarnRecovery: 0,
      currentBalance:
        member.memMoney + member.memPurchaseMoney + member.memAgencyMoney,
    };

    for (const item of allList) {
      const amount = item.mwlAmount || 0;
      switch (item.mwlType) {
        case MoneyWalletType.EARN:
          summary.totalEarn += amount;
          break;
        case MoneyWalletType.CHARGE:
          summary.totalCharge += amount;
          break;
        case MoneyWalletType.GRANT:
          summary.totalGrant += amount;
          break;
        case MoneyWalletType.EXPIRE:
          summary.totalExpire += Math.abs(amount);
          break;
        case MoneyWalletType.USE:
          summary.totalUse += Math.abs(amount);
          break;
        case MoneyWalletType.GRANT_RECOVERY:
          summary.totalGrantRecovery += Math.abs(amount);
          break;
        case MoneyWalletType.EARN_RECOVERY:
          summary.totalEarnRecovery += Math.abs(amount);
          break;
      }
    }

    return { count, list, summary };
  }

  // ============================================================================
  // 관리자 - 결제관리
  // ============================================================================

  /**
   * 결제 목록 조회
   */
  async getPaymentList(dto: GetMoneyPaymentListDto) {
    const page = dto.page ?? 1;
    const limit = dto.limit ?? 10;

    const [list, count] = await Promise.all([
      this.moneyRepository.getPaymentListWithSearch(dto, page, limit),
      this.moneyRepository.getPaymentCountWithSearch(dto),
    ]);

    return { count, list };
  }

  /**
   * 결제 목록 엑셀 다운로드
   */
  async downloadPaymentExcel(dto: GetMoneyPaymentListDto, res: Response) {
    // 전체 데이터 조회 (page=0 으로 전체 조회)
    const list = await this.moneyRepository.getPaymentListWithSearch(dto, 0, 0);

    const workbook = new Workbook();
    const worksheet = workbook.addWorksheet('결제내역');

    // 결제상태 라벨
    const getStatusLabel = (status: string) => {
      switch (status) {
        case 'COMPLETED':
          return '결제완료';
        case 'CANCELLED':
          return '결제취소';
        case 'PARTIAL_CANCELLED':
          return '부분취소';
        case 'CANCELLING':
          return '취소 처리 중';
        default:
          return status || '-';
      }
    };

    // 회원타입 라벨
    const getMemTypeLabel = (type: string) => {
      if (type === 'MEM001') return '일반';
      if (type === 'MEM002') return '기업';
      if (type === 'MEM003') return 'FC';
      return type || '-';
    };

    // 헤더 설정
    worksheet.columns = [
      { header: 'NO', key: 'no', width: 8 },
      { header: '영업대리점명', key: 'sagName', width: 15 },
      { header: '영업채널명', key: 'sacName', width: 15 },
      { header: '회원타입', key: 'memType', width: 10 },
      { header: '기업명', key: 'agcCorporateName', width: 15 },
      { header: '회원아이디', key: 'memLoginId', width: 15 },
      { header: '회원명', key: 'memName', width: 12 },
      { header: '주문번호', key: 'mpyOrderNumber', width: 20 },
      { header: '결제상태', key: 'mpyStatus', width: 10 },
      { header: '결제금액', key: 'mpyAmount', width: 12 },
      { header: '결제취소일', key: 'mpyCanceledAt', width: 18 },
      { header: '결제일', key: 'mpyCreatedAt', width: 18 },
    ];

    // 데이터 추가
    list.forEach((item: any, index: number) => {
      worksheet.addRow({
        no: index + 1,
        sagName: item.sagName || '미소속',
        sacName: item.sacName || '-',
        memType: getMemTypeLabel(item.memType),
        agcCorporateName: item.agcCorporateName || '-',
        memLoginId: item.memLoginId || '-',
        memName: item.memName || '-',
        mpyOrderNumber: item.mpyOrderNumber || '-',
        mpyStatus: getStatusLabel(item.mpyStatus),
        mpyAmount: item.mpyAmount || 0,
        mpyCanceledAt: item.mpyCanceledAt
          ? item.mpyCanceledAt.toString().substring(0, 16).replace('T', ' ')
          : '-',
        mpyCreatedAt: item.mpyCreatedAt
          ? item.mpyCreatedAt.toString().substring(0, 16).replace('T', ' ')
          : '-',
      });
    });

    // 헤더 스타일
    worksheet.getRow(1).font = { bold: true };
    worksheet.getRow(1).alignment = { horizontal: 'center' };

    await workbook.xlsx.write(res);
  }

  /**
   * 결제 상세 조회
   */
  async getPaymentDetail(mpyId: number, canSeeProcessed = false) {
    const payment = await this.moneyRepository.getPaymentById(mpyId);
    if (!payment) {
      throw new NotFoundException('결제 정보를 찾을 수 없습니다.');
    }

    const cancels = await this.moneyRepository.getPaymentCancels(
      mpyId,
      canSeeProcessed,
    );

    return { payment, cancels };
  }

  /**
   * 결제 이용내역 조회
   * 프론트엔드 기대 형식: { payment, usageList, cancelList, refundList, wallet, useOnlyAmount }
   */
  async getPaymentUsage(mpyId: number, canSeeProcessed = false) {
    const payment = await this.moneyRepository.getPaymentById(mpyId);
    if (!payment) {
      throw new NotFoundException('결제 정보를 찾을 수 없습니다.');
    }

    // source (wallet) 정보 조회
    const source = await this.moneyRepository.getSourceByPaymentId(mpyId);

    // 사용 내역 조회 (프론트엔드 형식: usageMthId, mumAmount, mthDescription, mthCreatedAt)
    // USE 타입(머니 사용)만 필터링
    const rawUsages = await this.moneyRepository.getPaymentUsageHistory(mpyId);
    const usageList = rawUsages
      .filter(
        (item: any) =>
          item.mwlType === MoneyWalletType.USE ||
          item.mwlType === MoneyWalletType.USE_CANCEL,
      )
      .map((item: any) => ({
        usageMthId: item.musId,
        mumAmount: item.musAmount,
        mthDescription: item.mwlDescription,
        mthCreatedAt: item.mwlCreatedAt,
        mthType: item.mwlType,
      }));

    // 취소 내역 조회 (프론트엔드 형식: mthId, mthAmount, mthDescription, mthCreatedAt)
    // mthAmount: PG 취소 금액 (mpyCanceledAmount) - 관리자용
    const rawCancels = await this.moneyRepository.getPaymentCancels(
      mpyId,
      canSeeProcessed,
    );
    const cancelList = rawCancels.map((item: any) => ({
      mthId: item.mpcId,
      mthAmount: payment.mpyCanceledAmount ?? item.mpcAmount,
      mthDescription: item.mpcReason,
      mthCreatedAt: item.mpcCreatedAt,
      //* 결제취소는 관리자가 하는 개입이다 — 이 매핑이 처리 계정을 떨어뜨리면 화면이 그릴 값이 없다(계획 M7 UI1).
      processedBy: item.processedBy,
    }));

    // 환불 내역 조회 - 해당 결제건(mpyId)과 연결된 환불만 조회
    const rawRefunds = await this.moneyRepository.getRefundsByPaymentId(mpyId);
    const refundList = rawRefunds.map((item: any) => ({
      mrfId: item.mrfId,
      mrfAmount: item.mrfAmountFromThis || item.mrfAmount, // 이 결제에서 차감된 금액
      mrfTotalAmount: item.mrfTotalAmount || item.mrfAmount, // 전체 환불 신청 금액
      mrfStatus: item.mrfStatus,
      mrfReason: item.mrfReason,
      mrfAdminMemo: item.mrfAdminMemo,
      mrfProcessedAt: item.mrfProcessedAt,
      mrfCreatedAt: item.mrfCreatedAt,
    }));

    // wallet 정보 변환 (프론트엔드 형식)
    const wallet = source
      ? {
          mwlOriginalAmount: source.msrOriginalAmount,
          mwlBalance: source.msrRemainingAmount,
        }
      : null;

    // 사용만 한 금액 계산 (환불 제외)
    const useOnlyAmount = usageList.reduce(
      (sum: number, item: any) => sum + (item.mumAmount || 0),
      0,
    );

    return {
      payment,
      usageList,
      cancelList,
      refundList,
      wallet,
      useOnlyAmount,
    };
  }

  /**
   * 관리자 결제 취소
   * 관리자는 사용된 머니가 있어도 전액 취소 가능
   * cancelAmount가 있으면 부분취소, 없으면 전액취소
   */
  async cancelPaymentByAdmin(admId: number | null, dto: CancelMoneyPaymentDto) {
    const payment = await this.moneyRepository.getPaymentById(dto.mpyId);
    if (!payment) {
      throw new NotFoundException('결제 정보를 찾을 수 없습니다.');
    }

    if (payment.mpyStatus === MoneyPaymentStatus.CANCELLED) {
      throw new BadRequestException('이미 취소된 결제입니다.');
    }

    // source 조회
    const source = await this.moneyRepository.getSourceByPaymentId(dto.mpyId);
    if (!source) {
      throw new BadRequestException('결제 소스 정보를 찾을 수 없습니다.');
    }

    // 취소 금액 결정 (부분취소 vs 전액취소)
    const cancelAmount = dto.cancelAmount || payment.mpyMoney;
    const isPartialCancel = !!(
      dto.cancelAmount && dto.cancelAmount < payment.mpyMoney
    );

    // 취소 금액 검증
    if (cancelAmount > payment.mpyMoney) {
      throw new BadRequestException(
        '취소 금액이 결제 금액을 초과할 수 없습니다.',
      );
    }

    // TID 검증
    if (!payment.mpyTid) {
      throw new BadRequestException('결제 거래번호(TID)가 없습니다.');
    }

    // 스마트로 PG사 결제 취소 API 호출
    const pgParams = {
      tid: payment.mpyTid,
      cancelReason: dto.mpcReason || '관리자 결제취소',
      amount: cancelAmount,
      isPartialCancel,
    };

    // 결제취소 처리
    // 부분취소 시: 남은 금액 전부 취소다. 잠금 아래 원천 잔여가 cancelAmount 와 다르면 PG 전에 거절된다
    // 전액취소 시: 잠금 아래에서 예약한 잔액(원천 잔여)만큼 차감 (유저 이력)
    // pgCancelAmount: 실제 PG사에 취소 요청한 금액 (관리자 결제취소금액)
    const walletDeductAmount = await this.cancelGeneralPaymentReserved(
      payment.memId,
      dto.mpyId,
      source.msrId,
      isPartialCancel ? cancelAmount : null,
      pgParams,
    );

    await this.processCancelPayment(
      dto.mpyId,
      payment.memId,
      source.msrId,
      walletDeductAmount,
      MoneyCancelType.ADMIN,
      dto.mpcReason,
      //* `processCancelPayment` 의 `admId` 는 선택 인자다 — '기록할 계정이 없다' 를
      //*  이미 부재로 표현하므로 null 을 undefined 로 맞춘다.
      admId ?? undefined,
      isPartialCancel,
      cancelAmount, // PG 취소 금액
    );

    return { success: true, message: '결제가 취소되었습니다.' };
  }

  // ============================================================================
  // 관리자 - 환불관리
  // ============================================================================

  /**
   * 환불 목록 조회
   */
  async getRefundList(dto: GetMoneyRefundListDto, canSeeProcessed = false) {
    const page = dto.page ?? 1;
    const limit = dto.limit ?? 10;

    const [list, count] = await Promise.all([
      this.moneyRepository.getRefundListWithSearch(
        dto,
        page,
        limit,
        canSeeProcessed,
      ),
      this.moneyRepository.getRefundCountWithSearch(dto),
    ]);

    return { count, list };
  }

  /**
   * 환불 상세 조회
   */
  async getRefundDetail(mrfId: number) {
    const refund = await this.moneyRepository.getRefundById(mrfId);
    if (!refund) {
      throw new NotFoundException('환불 정보를 찾을 수 없습니다.');
    }

    return { refund };
  }

  /**
   * 환불 승인/반려 처리
   */
  async processRefund(admId: number | null, dto: ProcessMoneyRefundDto) {
    const refund = await this.moneyRepository.getRefundById(dto.mrfId);
    if (!refund) {
      throw new NotFoundException('환불 정보를 찾을 수 없습니다.');
    }

    if (refund.mrfStatus !== MoneyRefundStatus.PENDING) {
      throw new BadRequestException('이미 처리된 환불 요청입니다.');
    }

    if (dto.action === 'APPROVED') {
      await this.approveRefund(admId, refund);
    } else {
      await this.rejectRefund(admId, refund, dto.mrfAdminMemo);
    }

    return { success: true };
  }

  /**
   * 환불 승인
   */
  private async approveRefund(admId: number | null, refund: any) {
    await this.moneyRepository.runInTransaction(async (tx) => {
      //* 처리 여부는 잠금 아래에서 다시 본다. 바깥의 PENDING 검사만으로는 두 요청이 함께 통과할 수 있다.
      const locked = await this.moneyRepository.getRefundByIdForUpdate(
        refund.mrfId,
        tx,
      );
      if (!locked || locked.mrfStatus !== MoneyRefundStatus.PENDING) {
        throw new BadRequestException('이미 처리된 환불 요청입니다.');
      }

      // 1. 환불 상태 업데이트
      await this.moneyRepository.updateRefundStatus(
        refund.mrfId,
        admId,
        MoneyRefundStatus.APPROVED,
        undefined,
        tx,
      );

      // 2. mwlId로 직접 REFUND_REQ wallet 찾아서 REFUND_OK로 업데이트
      if (refund.mwlId) {
        // 환불신청 → 환불승인으로 타입 및 설명 변경
        await this.moneyRepository.updateWalletType(
          refund.mwlId,
          MoneyWalletType.REFUND_OK,
          '환불 승인',
          tx,
        );
      }
    });
  }

  /**
   * 환불 반려
   */
  private async rejectRefund(
    admId: number | null,
    refund: any,
    adminMemo?: string,
  ) {
    await this.moneyRepository.runInTransaction(async (tx) => {
      //* 트랜잭션의 첫 질의는 회원 잠금이다.
      const member = await this.moneyRepository.getMemberByIdForUpdate(
        refund.memId,
        tx,
      );
      if (!member) {
        throw new NotFoundException('회원 정보를 찾을 수 없습니다.');
      }

      //* 처리 여부는 잠금 아래에서 다시 본다. 바깥의 PENDING 검사만으로는 두 요청이 함께 통과해 같은 금액을 두 번 복구한다.
      const lockedRefund = await this.moneyRepository.getRefundByIdForUpdate(
        refund.mrfId,
        tx,
      );
      if (
        !lockedRefund ||
        lockedRefund.mrfStatus !== MoneyRefundStatus.PENDING
      ) {
        throw new BadRequestException('이미 처리된 환불 요청입니다.');
      }

      // 1. mwlId로 직접 usage 조회 후 source remaining 복구 (원천은 잠금 읽기)
      let usages: { msrId: number; musAmount: number }[] = [];
      if (refund.mwlId) {
        usages = await this.moneyRepository.getUsagesByWalletId(
          refund.mwlId,
          tx,
        );

        for (const usage of usages) {
          const source = await this.moneyRepository.getSourceByIdForUpdate(
            usage.msrId,
            tx,
          );
          if (source) {
            // source remaining 복구
            const newRemaining = source.msrRemainingAmount + usage.musAmount;
            await this.moneyRepository.updateSourceRemaining(
              usage.msrId,
              newRemaining,
              tx,
            );
          }
        }
      }

      const newBalance =
        member.memMoney +
        member.memPurchaseMoney +
        member.memAgencyMoney +
        refund.mrfAmount;

      // 2. 환불 상태 업데이트
      await this.moneyRepository.updateRefundStatus(
        refund.mrfId,
        admId,
        MoneyRefundStatus.REJECTED,
        adminMemo,
        tx,
      );

      // 3. wallet 이력 생성 (REFUND_NO)
      const refundNoDescription = adminMemo
        ? `환불 반려 (사유: ${adminMemo})`
        : '환불 반려';
      const refundNoMwlId = await this.moneyRepository.createWallet(
        {
          memId: refund.memId,
          mwlType: MoneyWalletType.REFUND_NO,
          mwlAmount: refund.mrfAmount,
          mwlBalance: newBalance,
          mwlDescription: refundNoDescription,
        },
        tx,
      );

      // 4. REFUND_NO usage 기록 생성 (관리자 이용내역 표시용)
      for (const usage of usages) {
        await this.moneyRepository.createUsage(
          {
            mwlId: refundNoMwlId!,
            msrId: usage.msrId,
            musType: MoneyUsageType.RESTORE,
            musAmount: usage.musAmount,
          },
          tx,
        );
      }

      // 5. member 잔액 복구
      await this.moneyRepository.updateMemberMoney(
        refund.memId,
        member.memMoney,
        member.memPurchaseMoney + refund.mrfAmount, // PURCHASE 복구
        member.memAgencyMoney,
        tx,
      );
    });
  }

  // ============================================================================
  // 유저 - 머니 조회
  // ============================================================================

  /**
   * 내 머니 조회
   */
  async getUserMoney(memId: number) {
    const member = await this.moneyRepository.getMemberById(memId);
    if (!member) {
      throw new NotFoundException('회원 정보를 찾을 수 없습니다.');
    }

    return {
      totalMoney:
        member.memMoney + member.memPurchaseMoney + member.memAgencyMoney,
      memMoney: member.memMoney,
      memPurchaseMoney: member.memPurchaseMoney,
      memAgencyMoney: member.memAgencyMoney,
    };
  }

  /**
   * 내 머니 이용내역 조회
   */
  async getUserMoneyHistory(memId: number, dto: GetUserMoneyHistoryDto) {
    const member = await this.moneyRepository.getMemberById(memId);
    if (!member) {
      throw new NotFoundException('회원 정보를 찾을 수 없습니다.');
    }

    const where = this.buildUserHistoryWhere(dto);
    const page = dto.page ?? 1;
    const limit = dto.limit ?? 10;

    const [list, count] = await Promise.all([
      this.moneyRepository.getWalletListByMemId(memId, where, page, limit),
      this.moneyRepository.getWalletCountByMemId(memId, where),
    ]);

    return {
      count,
      list,
      totalMoney:
        member.memMoney + member.memPurchaseMoney + member.memAgencyMoney,
    };
  }

  private buildUserHistoryWhere(
    dto: GetUserMoneyHistoryDto,
  ): SQL<unknown> | undefined {
    const conditions: SQL<unknown>[] = [];

    // 기간 필터
    if (dto.period) {
      const now = dayjs();
      let startDate: string;

      switch (dto.period) {
        case '1w':
          startDate = now.subtract(1, 'week').format('YYYY-MM-DD');
          break;
        case '1m':
          startDate = now.subtract(1, 'month').format('YYYY-MM-DD');
          break;
        case '3m':
          startDate = now.subtract(3, 'month').format('YYYY-MM-DD');
          break;
        case '6m':
          startDate = now.subtract(6, 'month').format('YYYY-MM-DD');
          break;
        default:
          startDate = now.subtract(1, 'month').format('YYYY-MM-DD');
      }

      conditions.push(
        between(
          thMoneyWallet.mwlCreatedAt,
          `${startDate} 00:00:00`,
          `${now.format('YYYY-MM-DD')} 23:59:59`,
        ),
      );
    }

    // 탭 필터
    if (dto.tab === 'charge') {
      conditions.push(
        or(
          eq(thMoneyWallet.mwlType, MoneyWalletType.EARN),
          eq(thMoneyWallet.mwlType, MoneyWalletType.GRANT),
          eq(thMoneyWallet.mwlType, MoneyWalletType.CHARGE),
          eq(thMoneyWallet.mwlType, MoneyWalletType.REFUND_NO),
        )!,
      );
    } else if (dto.tab === 'use') {
      conditions.push(
        or(
          eq(thMoneyWallet.mwlType, MoneyWalletType.USE),
          eq(thMoneyWallet.mwlType, MoneyWalletType.EXPIRE),
          eq(thMoneyWallet.mwlType, MoneyWalletType.EARN_RECOVERY),
          eq(thMoneyWallet.mwlType, MoneyWalletType.GRANT_RECOVERY),
          eq(thMoneyWallet.mwlType, MoneyWalletType.CANCEL),
          eq(thMoneyWallet.mwlType, MoneyWalletType.REFUND_REQ),
          eq(thMoneyWallet.mwlType, MoneyWalletType.REFUND_OK),
        )!,
      );
    }

    return conditions.length > 0 ? and(...conditions) : undefined;
  }

  // ============================================================================
  // 유저 - 머니 충전 (스마트로 일반결제)
  // ============================================================================

  /**
   * 머니 충전 요청 (스마트로 일반결제 데이터 생성)
   */
  async requestCharge(memId: number, dto: RequestChargeDto) {
    const member = await this.moneyRepository.getMemberById(memId);
    if (!member) {
      throw new NotFoundException('회원 정보를 찾을 수 없습니다.');
    }

    return this.smartroService.createGeneralPaymentRequest({
      memId,
      amount: dto.amount,
      goodsName: dto.goodsName || '머니 충전',
      buyerName:
        (member.memName as string) == ''
          ? '인슈니버스'
          : (member.memName as string),
      buyerTel: String(member.memPhone ?? '').trim() || '01000000000',
      buyerEmail:
        (member.memEmail as string) == '' ? '' : (member.memEmail as string),
    });
  }

  /**
   * 머니 충전 승인 처리 (스마트로 콜백)
   */
  async processChargeApproval(body: any) {
    const { Tid, TrAuthKey } = body;

    // 스마트로 일반결제 승인 API 호출
    const approvalResult = await this.smartroService.approveGeneralPayment({
      tid: Tid,
      trAuthKey: TrAuthKey,
    });

    if (!approvalResult.success) {
      return {
        success: false,
        message: approvalResult.resultMsg || '결제 승인에 실패했습니다.',
        resultCode: approvalResult.resultCode,
      };
    }

    const { moid, amount, cardNum, cardName, cardType } = approvalResult;

    // Moid에서 memId 추출 (CHARGE_memId_timestamp 형식)
    const moidParts = moid?.split('_') || [];
    if (moidParts.length < 2) {
      return {
        success: false,
        message: '잘못된 주문번호입니다.',
      };
    }

    const memId = parseInt(moidParts[1]);

    // 1. 결제 정보 저장
    const mpyId = await this.moneyRepository.createPayment({
      memId,
      mpyOrderNumber: moid!,
      mpyTid: Tid,
      mpyAmount: amount!,
      mpyMoney: amount!,
      mpyStatus: MoneyPaymentStatus.COMPLETED,
      mpyCardType: cardType || null,
      mpyCardName: cardName || null,
      mpyCardNumber: cardNum || null,
      mpyApprovedAt: new Date(),
    });

    // 2. 머니 충전 처리
    await this.chargeMoney(memId, mpyId!, amount!, '머니 충전');

    // 3. 알림톡 발송 (실패해도 결제는 성공 처리)
    try {
      const member = await this.moneyRepository.getMemberById(memId);
      if (member) {
        const newBalance =
          member.memMoney + member.memPurchaseMoney + member.memAgencyMoney;
        this.logger.log(
          `[머니 충전 알림톡] memId=${memId}, amount=${amount}, balance=${newBalance}`,
        );
        await this.alimtalkService.sendMoneyChargeSuccess({
          memId,
          amount: amount!,
          balance: newBalance,
        });
        this.logger.log(`[머니 충전 알림톡 발송 완료] memId=${memId}`);
      } else {
        this.logger.warn(
          `[머니 충전 알림톡 스킵] 회원 정보 없음: memId=${memId}`,
        );
      }
    } catch (alimtalkError) {
      this.logger.error(
        `[머니 충전 알림톡 발송 실패] memId=${memId}:`,
        alimtalkError,
      );
    }

    return {
      success: true,
      message: '머니 충전이 완료되었습니다.',
      data: {
        tid: Tid,
        amount,
        moid,
      },
    };
  }

  /**
   * 머니 충전 (CHARGE)
   */
  private async chargeMoney(
    memId: number,
    mpyId: number,
    amount: number,
    title: string,
  ) {
    try {
      await this.moneyRepository.runInTransaction(async (tx) => {
        const member = await this.moneyRepository.getMemberByIdForUpdate(
          memId,
          tx,
        );
        if (!member) {
          throw new NotFoundException('회원 정보를 찾을 수 없습니다.');
        }

        const newBalance =
          member.memMoney +
          member.memPurchaseMoney +
          member.memAgencyMoney +
          amount;

        // 1. wallet 이력 생성
        const mwlId = await this.moneyRepository.createWallet(
          {
            memId,
            mwlType: MoneyWalletType.CHARGE,
            mwlAmount: amount,
            mwlBalance: newBalance,
            mwlDescription: title,
          },
          tx,
        );

        // 2. source 생성
        await this.moneyRepository.createSource(
          {
            memId,
            mwlId: mwlId!,
            msrType: MoneySourceType.CHARGE,
            msrMoneyType: MoneyMoneyType.PURCHASE,
            msrOriginalAmount: amount,
            msrRemainingAmount: amount,
            mniId: null,
            mngId: null,
            mpyId,
            msrExpiredAt: null,
          },
          tx,
        );

        // 3. member 잔액 업데이트
        await this.moneyRepository.updateMemberMoney(
          memId,
          member.memMoney,
          member.memPurchaseMoney + amount,
          member.memAgencyMoney,
          tx,
        );
      });
    } catch (error) {
      //* 결제 행은 COMPLETED 로 이미 커밋됐다. 머니만 빠졌으므로 식별자로 찾아 보정할 수 있게 남기고 그대로 던진다.
      this.logger.error(
        `[머니 충전 실패 · 결제 행 커밋됨] memId=${memId}, mpyId=${mpyId}, amount=${amount}: ${error instanceof Error ? error.message.split('\n')[0] : String(error)}`,
      );
      throw error;
    }
  }

  // ============================================================================
  // 유저 - 결제 취소
  // ============================================================================

  /**
   * 결제 취소 가능 여부 확인
   */
  async checkPaymentCancellable(memId: number, mpyTid: string) {
    const payment = await this.moneyRepository.getPaymentByMemIdAndTid(
      memId,
      mpyTid,
    );
    if (!payment) {
      return { cancellable: false, reason: '결제 정보를 찾을 수 없습니다.' };
    }

    if (payment.mpyStatus === MoneyPaymentStatus.CANCELLED) {
      return { cancellable: false, reason: '이미 취소된 결제입니다.' };
    }

    if (payment.mpyStatus === MoneyPaymentStatus.CANCELLING) {
      return { cancellable: false, reason: '취소 처리 중인 결제입니다.' };
    }

    // 7일 이내 확인
    const daysSincePayment = dayjs().diff(dayjs(payment.mpyApprovedAt), 'day');
    if (daysSincePayment > 7) {
      return {
        cancellable: false,
        reason: '결제 후 7일이 지나 취소가 불가능합니다.',
      };
    }

    // source 확인
    const source = await this.moneyRepository.getSourceByPaymentId(
      payment.mpyId,
    );
    if (!source || source.msrRemainingAmount !== source.msrOriginalAmount) {
      return {
        cancellable: false,
        reason: '이미 사용된 머니가 있어 취소할 수 없습니다.',
      };
    }

    const fullPayment = await this.moneyRepository.getPaymentById(
      payment.mpyId,
    );

    return { cancellable: true, payment: fullPayment };
  }

  /**
   * 유저 결제 취소
   */
  async cancelCharge(memId: number, dto: CancelChargeDto) {
    const payment = await this.moneyRepository.getPaymentByMemIdAndTid(
      memId,
      dto.mpyTid,
    );
    if (!payment) {
      throw new NotFoundException('결제 정보를 찾을 수 없습니다.');
    }

    const checkResult = await this.checkPaymentCancellable(memId, dto.mpyTid);
    if (!checkResult.cancellable) {
      throw new BadRequestException(checkResult.reason);
    }

    const source = await this.moneyRepository.getSourceByPaymentId(
      payment.mpyId,
    );
    if (!source) {
      throw new BadRequestException('충전 정보를 찾을 수 없습니다.');
    }

    // TID 검증
    if (!payment.mpyTid) {
      throw new BadRequestException('결제 거래번호(TID)가 없습니다.');
    }

    // 스마트로 PG사 결제 취소 API 호출 (유저는 전액취소만 가능). 잠금 아래 미사용을 다시 보고 원천을 예약한 뒤 부른다.
    await this.cancelGeneralPaymentReserved(
      memId,
      payment.mpyId,
      source.msrId,
      payment.mpyMoney,
      {
        tid: payment.mpyTid,
        cancelReason: dto.mpcReason || '유저 결제취소',
        amount: payment.mpyMoney,
        isPartialCancel: false,
      },
    );

    await this.processCancelPayment(
      payment.mpyId,
      memId,
      source.msrId,
      payment.mpyMoney,
      MoneyCancelType.USER,
      dto.mpcReason,
    );

    return { success: true };
  }

  /**
   * 취소의 PG 호출을 결제 · 원천 예약으로 감싼다.
   * 회원 잠금 아래 결제를 CANCELLING 으로, 원천 잔여를 0 으로 커밋한 뒤 PG 를 부른다. 결제 표지가 있어 같은 결제의 두 번째
   * 취소 (관리자 전액 · 부분 · 재시도 · 사용자 취소 대기 중 관리자 취소) 는 PG 를 부르기 전에 거절된다. 예약 중에는 사용 · 환불
   * 차감이 이 원천을 고르지 않는다 (사용 복구 · 환불 반려로 잔여가 생겨도 마찬가지다). PG 호출은 잠금 밖이다 — 이 서비스의
   * PG 호출은 모두 트랜잭션 밖에서 하고, 응답 대기 (최대 30초) 동안 회원 행을 쥐지 않는다.
   * PG 호출이 실패하면 종류를 가리지 않고 예약을 둔 채 로그를 남기고 던진다. 거절 코드로는 환불 없음을 확정하지 못해 (`2015` 는 표마다 뜻이 다르다) 예약은 결과 확인(resolveCancellingPayment)이 끝낸다. 남은 건은 알림 money-cancelling 이 알린다.
   * 예약과 반영 사이에 프로세스가 죽어도 같은 상태로 남는다 (머니는 못 쓰는 쪽).
   * shortcut: 평범한 거절도 결과 확인을 거쳐야 풀린다, 거절이 잦아지면 실측한 거절 코드만 허용 목록으로 즉시 해제한다
   * @param expectedRemaining - 잠금 아래 원천 잔여가 이 값이어야 한다 (null 이면 검사하지 않는다)
   * @returns 예약한 원천 잔여 (DB 반영 때 칸에서 뺄 금액)
   */
  private async cancelGeneralPaymentReserved(
    memId: number,
    mpyId: number,
    msrId: number,
    expectedRemaining: number | null,
    params: Parameters<MoneyService['cancelGeneralPayment']>[0],
  ): Promise<number> {
    const reserved = await this.moneyRepository.runInTransaction(async (tx) => {
      const member = await this.moneyRepository.getMemberByIdForUpdate(
        memId,
        tx,
      );
      if (!member) {
        throw new NotFoundException('회원 정보를 찾을 수 없습니다.');
      }
      const payment = await this.moneyRepository.getPaymentByIdForUpdate(
        mpyId,
        tx,
      );
      if (
        !payment ||
        payment.mpyStatus === MoneyPaymentStatus.CANCELLED ||
        payment.mpyStatus === MoneyPaymentStatus.CANCELLING
      ) {
        throw new BadRequestException(
          '이미 취소됐거나 취소 처리 중인 결제입니다.',
        );
      }
      const source = await this.moneyRepository.getSourceByIdForUpdate(
        msrId,
        tx,
      );
      if (!source) {
        throw new BadRequestException('결제 소스 정보를 찾을 수 없습니다.');
      }
      if (
        expectedRemaining !== null &&
        source.msrRemainingAmount !== expectedRemaining
      ) {
        throw new BadRequestException(
          '취소 금액이 남은 금액과 달라 취소하지 않았습니다. 다시 확인해 주세요.',
        );
      }
      //* 이 원천에서 나간 대기 환불은 승인되면 PG 환불과 겹친다. 회원 잠금 아래라 새 환불이 끼어들지 못한다.
      if (await this.moneyRepository.hasPendingRefundOnSource(msrId, tx)) {
        throw new BadRequestException(
          '환불 신청 중인 내역이 있어 결제를 취소할 수 없습니다.',
        );
      }
      await this.moneyRepository.reservePaymentCancel(
        mpyId,
        source.msrRemainingAmount,
        tx,
      );
      await this.moneyRepository.updateSourceRemaining(msrId, 0, tx);
      return source.msrRemainingAmount as number;
    });

    try {
      await this.cancelGeneralPayment(params);
    } catch (error) {
      this.logger.error(
        `[PG 결제취소 결과 불명] mpyId=${mpyId}, memId=${memId}, msrId=${msrId}, amount=${reserved}: ${error instanceof Error ? error.message.split('\n')[0] : String(error)}`,
      );
      throw error;
    }
    return reserved;
  }

  /**
   * 결제 취소 처리 (공통)
   * @param amount - 유저 머니에서 차감할 금액 (유저 이력에 표시)
   * @param pgCancelAmount - PG사에 취소 요청한 금액 (관리자 결제취소금액에 표시)
   */
  private async processCancelPayment(
    mpyId: number,
    memId: number,
    msrId: number,
    amount: number,
    cancelType: MoneyCancelType,
    reason?: string,
    admId?: number,
    isPartialCancel?: boolean,
    pgCancelAmount?: number,
  ) {
    try {
      await this.moneyRepository.runInTransaction(async (tx) => {
        //* 트랜잭션의 첫 질의는 회원 잠금이다. 아래 칸 계산은 잠금 뒤의 값으로 한다.
        const member = await this.moneyRepository.getMemberByIdForUpdate(
          memId,
          tx,
        );
        if (!member) {
          throw new NotFoundException('회원 정보를 찾을 수 없습니다.');
        }

        //* 취소는 PG 호출 전에 결제를 CANCELLING, 원천을 0 으로 예약했다 (cancelGeneralPaymentReserved). 예약이 없거나
        //*  예약 뒤 원천이 움직였으면 칸과 원천이 어긋나므로 던져 이 트랜잭션을 되돌리고 아래 catch 의 로그로 드러낸다.
        const cancelPayment =
          await this.moneyRepository.getPaymentByIdForUpdate(mpyId, tx);
        if (cancelPayment?.mpyStatus !== MoneyPaymentStatus.CANCELLING) {
          throw new BadRequestException(
            '결제취소 예약이 없어 취소를 반영하지 않았다.',
          );
        }
        const cancelSource = await this.moneyRepository.getSourceByIdForUpdate(
          msrId,
          tx,
        );
        if (!cancelSource || cancelSource.msrRemainingAmount !== 0) {
          throw new BadRequestException(
            '결제취소 예약 뒤 원천 잔여가 달라 취소를 반영하지 않았다.',
          );
        }

        await this.writeCancelRows(tx, member, {
          mpyId,
          memId,
          msrId,
          amount,
          remainingAfter: 0,
          canceledAmount: pgCancelAmount ?? amount,
          cancelType,
          reason,
          admId,
          isPartialCancel,
        });
      });
    } catch (error) {
      //* 두 호출자 모두 PG 취소가 끝난 뒤 이 메서드를 부른다. 여기서 실패하면 PG 는 환불됐고 DB 는 그대로다 (원천은
      //*  예약된 0 이라 그 머니는 쓸 수 없다). 예약이 남으니 결과 확인이 끝낸다. 식별자로 찾을 수 있게 남기고 그대로 던진다.
      this.logger.error(
        `[PG 결제취소 뒤 DB 반영 실패] mpyId=${mpyId}, memId=${memId}, type=${cancelType}, amount=${amount}: ${error instanceof Error ? error.message.split('\n')[0] : String(error)}`,
      );
      throw error;
    }
  }

  /**
   * 취소의 DB 쓰기. 회원 · 결제 · 원천을 잠근 트랜잭션 안에서 부른다.
   * @param amount - 회원 칸에서 뺄 금액 (유저 이력에 표시)
   * @param remainingAfter - 원천에 남길 잔여
   * @param canceledAmount - PG 취소 금액 (관리자 결제취소금액에 표시)
   */
  private async writeCancelRows(
    tx: DrizzleService,
    member: {
      memMoney: number;
      memPurchaseMoney: number;
      memAgencyMoney: number;
    },
    {
      mpyId,
      memId,
      msrId,
      amount,
      remainingAfter,
      canceledAmount,
      cancelType,
      reason,
      admId,
      isPartialCancel,
    }: {
      mpyId: number;
      memId: number;
      msrId: number;
      amount: number;
      remainingAfter: number;
      canceledAmount: number;
      cancelType: MoneyCancelType;
      reason?: string;
      admId?: number;
      isPartialCancel?: boolean;
    },
  ) {
    const newBalance =
      member.memMoney +
      member.memPurchaseMoney +
      member.memAgencyMoney -
      amount;

    // 1. payment 상태 업데이트 (PG 취소 금액 저장)
    await this.moneyRepository.updatePaymentStatus(
      mpyId,
      MoneyPaymentStatus.CANCELLED,
      canceledAmount,
      tx,
    );

    // 2. 취소 이력 저장
    let defaultReason = '';
    if (cancelType === MoneyCancelType.ADMIN) {
      const cancelTypeText = isPartialCancel ? '부분취소' : '전액취소';
      defaultReason = `관리자 결제취소 (${cancelTypeText})`;
    } else {
      defaultReason = '충전 취소';
    }
    await this.moneyRepository.createPaymentCancel(
      {
        mpyId,
        admId: admId || null,
        mpcType: cancelType,
        mpcAmount: amount,
        mpcReason: reason || defaultReason,
        mpcSequence: 1,
      },
      tx,
    );

    // 3. source remaining 차감
    await this.moneyRepository.updateSourceRemaining(msrId, remainingAfter, tx);

    // 4. wallet 이력 생성 (CANCEL) - 유저에게는 전액/부분 구분 없이 표시
    let description = '';
    if (cancelType === MoneyCancelType.ADMIN) {
      description = reason ? `관리자 결제취소: ${reason}` : '관리자 결제취소';
    } else {
      description = reason ? `충전 취소: ${reason}` : '충전 취소';
    }

    const mwlId = await this.moneyRepository.createWallet(
      {
        memId,
        mwlType: MoneyWalletType.CANCEL,
        mwlAmount: -amount,
        mwlBalance: newBalance,
        mwlDescription: description,
      },
      tx,
    );

    // 5. usage 기록
    await this.moneyRepository.createUsage(
      {
        mwlId: mwlId!,
        msrId,
        musType: MoneyUsageType.DEDUCT,
        musAmount: amount,
      },
      tx,
    );

    // 6. member 잔액 업데이트
    await this.moneyRepository.updateMemberMoney(
      memId,
      member.memMoney,
      member.memPurchaseMoney - amount,
      member.memAgencyMoney,
      tx,
    );
  }

  /**
   * 결제 취소 미확정(CANCELLING) 건을 PG 장부로 끝낸다. 판정은 서버가 하고 누르는 사람은 고르지 않는다. PG 쓰기는 없다.
   * 조회는 잠금 밖에서 하고, 반영은 회원 → 결제 → 원천 잠금 아래 같은 예약인지 다시 본다. 뒤에 온 요청 · 늦게 도착한 옛 조회는
   * 상태와 예약 시각에서 거절된다.
   * shortcut: 사람이 눌러야 끝난다, 조회를 시험 상점으로 실측한 뒤 같은 메서드를 @Cron 으로 부른다
   */
  async resolveCancellingPayment(
    admId: number | null,
    mpyId: number,
    ctx: AuditContext,
  ) {
    const payment = await this.moneyRepository.getPaymentById(mpyId);
    if (!payment) {
      throw new NotFoundException('결제 정보를 찾을 수 없습니다.');
    }
    if (payment.mpyStatus !== MoneyPaymentStatus.CANCELLING) {
      throw new BadRequestException(NOT_CANCELLING_MESSAGE);
    }
    const source = await this.moneyRepository.getSourceByPaymentId(mpyId);
    if (!source) {
      throw new BadRequestException('결제 소스 정보를 찾을 수 없습니다.');
    }
    if (!payment.mpyTid) {
      throw new BadRequestException('결제 거래번호(TID)가 없습니다.');
    }
    //* 이 기능 전의 예약은 취소액 칸이 0 이고 수정일로는 가릴 수 없다. 새 예약의 0 은 원장도 0 이다
    const reserved =
      payment.mpyCanceledAmount ||
      (await this.moneyRepository.getCancelReservedFromUsage(source.msrId));
    if (!(reserved >= 0 && reserved <= source.msrOriginalAmount)) {
      throw new BadRequestException(
        '취소 금액 기록이 없는 건이라 확인할 수 없습니다.',
      );
    }

    const { stateCd, partAmounts } =
      await this.smartroService.lookupCancelState(payment.mpyTid);

    const pgRefund =
      partAmounts.length > 0
        ? partAmounts.reduce((sum, amount) => sum + amount, 0)
        : stateCd === '0'
          ? 0
          : payment.mpyMoney;
    const result = cancelResolveResult(pgRefund, payment.mpyMoney, reserved);
    if (!result) {
      this.logger.error(
        `[결제 취소 결과 판정 불가] mpyId=${mpyId}, stateCd=${stateCd}, pgRefund=${pgRefund}, reserved=${reserved}`,
      );
      throw new BadRequestException(
        '결제사 기록이 취소 요청과 달라 그대로 두었습니다.',
      );
    }

    const statusAfter =
      result === 'RELEASED'
        ? MoneyPaymentStatus.COMPLETED
        : MoneyPaymentStatus.CANCELLED;
    const txStartedAt = process.hrtime.bigint();
    let pending: MoneyAuditFields | null = null;
    await this.moneyRepository.runInTransaction(async (tx) => {
      const member = await this.moneyRepository.getMemberByIdForUpdate(
        payment.memId,
        tx,
      );
      if (!member) {
        throw new NotFoundException('회원 정보를 찾을 수 없습니다.');
      }
      const current = await this.moneyRepository.getPaymentByIdForUpdate(
        mpyId,
        tx,
      );
      if (
        current?.mpyStatus !== MoneyPaymentStatus.CANCELLING ||
        current.mpyUpdatedAt !== payment.mpyUpdatedAt
      ) {
        throw new BadRequestException(NOT_CANCELLING_MESSAGE);
      }
      if (
        result === 'RELEASED' &&
        current.secondsSinceUpdate < CANCEL_RELEASE_MIN_SECONDS
      ) {
        throw new BadRequestException(
          '결제사에 아직 취소 기록이 없어 그대로 두었습니다. 취소 요청 24시간 뒤에 다시 확인해 주세요.',
        );
      }
      const locked = await this.moneyRepository.getSourceByIdForUpdate(
        source.msrId,
        tx,
      );
      if (!locked) {
        throw new BadRequestException('결제 소스 정보를 찾을 수 없습니다.');
      }

      //* 원천은 예약 때 0 이 됐으므로 지금 잔여가 예약 중 복구분이다
      const recovered = locked.msrRemainingAmount;
      const deducted =
        result === 'RELEASED'
          ? 0
          : result === 'REFUNDED_FULL'
            ? reserved + recovered
            : reserved;
      if (result === 'RELEASED') {
        await this.moneyRepository.releasePaymentCancel(mpyId, tx);
        await this.moneyRepository.updateSourceRemaining(
          source.msrId,
          reserved + recovered,
          tx,
        );
      } else {
        await this.writeCancelRows(tx, member, {
          mpyId,
          memId: payment.memId,
          msrId: source.msrId,
          amount: deducted,
          //* 전액 환불은 복구분까지 PG 가 돌려줬다. 부분 환불은 그 금액이 환불 밖이라 남긴다
          remainingAfter: result === 'REFUNDED_FULL' ? 0 : recovered,
          canceledAmount: pgRefund,
          cancelType: MoneyCancelType.ADMIN,
          admId: admId ?? undefined,
          isPartialCancel: result === 'REFUNDED_PARTIAL',
        });
      }

      pending = {
        targetType: 'payment',
        targetId: mpyId,
        amount: 0 - deducted,
        targetCount: 1,
        //* 사람이 고르지 않는다. 결제 상태를 PG 장부에 맞추는 동작이다
        reasonCode: 'CORRECTION',
        changes: {
          memId: payment.memId,
          msrId: source.msrId,
          resolveType: result,
          pgStateCd: stateCd,
          pgRefundAmount: pgRefund,
          reservedAmount: reserved,
          recoveredAmount: recovered,
          statusBefore: current.mpyStatus,
          statusAfter,
          purchaseMoneyBefore: member.memPurchaseMoney,
          purchaseMoneyAfter: member.memPurchaseMoney - deducted,
        },
      };
      await this.appendMoneyAudit(
        tx,
        ctx,
        AUDIT_ACTION.moneyPaymentCancelResolve,
        pending,
      );
    });
    this.recordMoneyAudit(
      ctx,
      AUDIT_ACTION.moneyPaymentCancelResolve,
      pending,
      msSince(txStartedAt),
    );

    return {
      success: true,
      result,
      message: CANCEL_RESOLVE_MESSAGE[result],
      mpyStatus: statusAfter,
      mpyCanceledAmount: result === 'RELEASED' ? 0 : pgRefund,
    };
  }

  // ============================================================================
  // 유저 - 환불 신청
  // ============================================================================

  /**
   * 환불 가능 정보 조회
   */
  async getRefundInfo(memId: number) {
    const member = await this.moneyRepository.getMemberById(memId);
    if (!member) {
      throw new NotFoundException('회원 정보를 찾을 수 없습니다.');
    }

    // 환불 가능: PURCHASE만 (BASIC, AGENCY는 불가)
    const refundableAmount = member.memPurchaseMoney;
    const totalMoney =
      member.memMoney + member.memPurchaseMoney + member.memAgencyMoney;

    return { refundableAmount, totalMoney };
  }

  /**
   * 환불 신청
   */
  async requestRefund(memId: number, dto: RequestRefundDto) {
    return await this.moneyRepository.runInTransaction(async (tx) => {
      //* 트랜잭션의 첫 질의는 회원 잠금이다. 환불 가능 금액 검사도 잠금 뒤의 칸으로 한다.
      const member = await this.moneyRepository.getMemberByIdForUpdate(
        memId,
        tx,
      );
      if (!member) {
        throw new NotFoundException('회원 정보를 찾을 수 없습니다.');
      }

      // 환불 가능: PURCHASE만 (BASIC, AGENCY는 불가)
      const refundableAmount = member.memPurchaseMoney;
      if (dto.mrfAmount > refundableAmount) {
        throw new BadRequestException('환불 가능 금액을 초과했습니다.');
      }

      if (dto.mrfAmount < 1000) {
        throw new BadRequestException('최소 환불 금액은 1,000원입니다.');
      }

      // 수수료 계산 (7일 이내 미사용: 0%, 그 외: 5%)
      // 간소화: 일단 5% 적용
      const fee = Math.floor(dto.mrfAmount * 0.05);
      const actualAmount = dto.mrfAmount - fee;

      // 1. FIFO 차감 (PURCHASE 우선, 원천은 잠금 읽기)
      const usages = await this.deductForRefund(memId, dto.mrfAmount, tx);

      const newBalance =
        member.memMoney +
        member.memPurchaseMoney +
        member.memAgencyMoney -
        dto.mrfAmount;

      // 2. wallet 이력 생성 (REFUND_REQ)
      const mwlId = await this.moneyRepository.createWallet(
        {
          memId,
          mwlType: MoneyWalletType.REFUND_REQ,
          mwlAmount: -dto.mrfAmount,
          mwlBalance: newBalance,
          mwlDescription: '환불 신청',
        },
        tx,
      );

      // 3. 환불 신청 생성 (mwlId 포함)
      const mrfId = await this.moneyRepository.createRefund(
        {
          memId,
          mwlId: mwlId!,
          admId: null,
          mrfStatus: MoneyRefundStatus.PENDING,
          mrfAmount: dto.mrfAmount,
          mrfFee: fee,
          mrfActualAmount: actualAmount,
          mrfBankName: dto.mrfBankName,
          mrfAccountNumber: dto.mrfAccountNumber,
          mrfAccountHolder: dto.mrfAccountHolder,
          mrfReason: dto.mrfReason,
          mrfAdminMemo: null,
          mrfProcessedAt: null,
        },
        tx,
      );

      // 4. usage 기록
      for (const usage of usages) {
        await this.moneyRepository.createUsage(
          {
            mwlId: mwlId!,
            msrId: usage.msrId,
            musType: MoneyUsageType.DEDUCT,
            musAmount: usage.amount,
          },
          tx,
        );
      }

      // 5. member 잔액 업데이트
      await this.moneyRepository.updateMemberMoney(
        memId,
        member.memMoney,
        Math.max(0, member.memPurchaseMoney - dto.mrfAmount),
        member.memAgencyMoney,
        tx,
      );

      return {
        success: true,
        mrfId,
        mrfAmount: dto.mrfAmount,
        mrfFee: fee,
        mrfActualAmount: actualAmount,
      };
    });
  }

  /**
   * 환불 신청 취소
   */
  async cancelRefund(memId: number, mrfId: number) {
    const refund = await this.moneyRepository.getRefundById(mrfId);
    if (!refund) {
      throw new NotFoundException('환불 정보를 찾을 수 없습니다.');
    }

    if (refund.memId !== memId) {
      throw new BadRequestException('본인의 환불 신청만 취소할 수 있습니다.');
    }

    if (refund.mrfStatus !== MoneyRefundStatus.PENDING) {
      throw new BadRequestException(
        '대기 중인 환불 신청만 취소할 수 있습니다.',
      );
    }

    // 환불 삭제 (또는 상태 변경)
    await this.moneyRepository.deleteRefund(mrfId);

    // 머니 복구 로직은 rejectRefund와 유사
    // 간소화를 위해 생략 (실제로는 구현 필요)

    return { success: true };
  }

  // ============================================================================
  // FIFO 차감 로직
  // ============================================================================

  /**
   * FIFO 순서로 source에서 차감
   */
  private async deductFromSources(
    memId: number,
    amount: number,
    moneyType?: MoneyMoneyType,
    tx?: any,
    excludeExpired: boolean = false,
  ): Promise<{ msrId: number; amount: number; msrMoneyType: string }[]> {
    const sources = await this.moneyRepository.getSourcesForDeduct(
      memId,
      tx,
      excludeExpired,
    );
    const usages: { msrId: number; amount: number; msrMoneyType: string }[] =
      [];
    let remaining = amount;

    for (const source of sources) {
      if (remaining <= 0) break;

      // 특정 머니 타입만 차감하는 경우
      if (moneyType && source.msrMoneyType !== moneyType) {
        continue;
      }

      const deductAmount = Math.min(remaining, source.msrRemainingAmount);

      await this.moneyRepository.updateSourceRemaining(
        source.msrId,
        source.msrRemainingAmount - deductAmount,
        tx,
      );

      usages.push({
        msrId: source.msrId,
        amount: deductAmount,
        msrMoneyType: source.msrMoneyType,
      });
      remaining -= deductAmount;
    }

    if (remaining > 0) {
      throw new BadRequestException(
        !moneyType &&
        (await this.moneyRepository.hasCancellingPayment(memId, tx))
          ? CANCELLING_SHORTFALL_MESSAGE
          : '머니가 부족합니다.',
      );
    }

    return usages;
  }

  /**
   * 환불을 위한 FIFO 차감 (PURCHASE만)
   */
  private async deductForRefund(
    memId: number,
    amount: number,
    tx?: any,
  ): Promise<{ msrId: number; amount: number }[]> {
    const sources = await this.moneyRepository.getSourcesForRefund(memId, tx);
    const usages: { msrId: number; amount: number }[] = [];
    let remaining = amount;

    for (const source of sources) {
      if (remaining <= 0) break;

      const deductAmount = Math.min(remaining, source.msrRemainingAmount);

      await this.moneyRepository.updateSourceRemaining(
        source.msrId,
        source.msrRemainingAmount - deductAmount,
        tx,
      );

      usages.push({ msrId: source.msrId, amount: deductAmount });
      remaining -= deductAmount;
    }

    // PURCHASE가 부족하면 에러 (AGENCY는 환불 불가)
    if (remaining > 0) {
      throw new BadRequestException(
        (await this.moneyRepository.hasCancellingPayment(memId, tx))
          ? CANCELLING_SHORTFALL_MESSAGE
          : '충전 머니가 부족합니다.',
      );
    }

    return usages;
  }

  // ============================================================================
  // 머니 사용 (청구서 발급 등)
  // ============================================================================

  /**
   * 머니 사용 (외부에서 호출)
   */
  async useMoney(memId: number, amount: number, description: string) {
    return await this.moneyRepository.runInTransaction(async (tx) => {
      //* 트랜잭션의 첫 질의는 회원 잠금이다. 뒤의 원천 조회가 잠금 뒤의 스냅샷을 잡는다.
      const member = await this.moneyRepository.getMemberByIdForUpdate(
        memId,
        tx,
      );
      if (!member) {
        throw new NotFoundException('회원 정보를 찾을 수 없습니다.');
      }

      const totalMoney =
        member.memMoney + member.memPurchaseMoney + member.memAgencyMoney;
      if (totalMoney < amount) {
        throw new BadRequestException('머니가 부족합니다.');
      }

      // FIFO 차감 (만료 원천은 뺀다. 소멸 대상은 소멸 경로가 0 으로 만든다)
      const usages = await this.deductFromSources(
        memId,
        amount,
        undefined,
        tx,
        true,
      );

      const newBalance = totalMoney - amount;

      // wallet 이력 생성
      const mwlId = await this.moneyRepository.createWallet(
        {
          memId,
          mwlType: MoneyWalletType.USE,
          mwlAmount: -amount,
          mwlBalance: newBalance,
          mwlDescription: description,
        },
        tx,
      );

      // usage 기록
      for (const usage of usages) {
        await this.moneyRepository.createUsage(
          {
            mwlId: mwlId!,
            msrId: usage.msrId,
            musType: MoneyUsageType.DEDUCT,
            musAmount: usage.amount,
          },
          tx,
        );
      }

      // member 잔액 계산 및 업데이트
      // usage에서 각 타입별 차감 금액 계산
      let basicDeduct = 0;
      let purchaseDeduct = 0;
      let agencyDeduct = 0;

      for (const usage of usages) {
        if (usage.msrMoneyType === MoneyMoneyType.BASIC) {
          basicDeduct += usage.amount;
        } else if (usage.msrMoneyType === MoneyMoneyType.PURCHASE) {
          purchaseDeduct += usage.amount;
        } else if (usage.msrMoneyType === MoneyMoneyType.AGENCY) {
          agencyDeduct += usage.amount;
        }
      }

      await this.moneyRepository.updateMemberMoney(
        memId,
        Math.max(0, member.memMoney - basicDeduct),
        Math.max(0, member.memPurchaseMoney - purchaseDeduct),
        Math.max(0, member.memAgencyMoney - agencyDeduct),
        tx,
      );

      return { success: true, usedAmount: amount, mwlId: mwlId! };
    });
  }

  /**
   * 머니 사용 취소 (부분 환불 지원)
   * - useMoney로 차감된 내역을 원래 타입(BASIC/PURCHASE/AGENCY)별로 정확히 복원
   * - 부분 환불: mwlId의 usage 중 restoreAmount만큼만 FIFO 역순으로 복원
   *
   * @param mwlId - useMoney 시 생성된 wallet ID
   * @param restoreAmount - 복원할 금액 (전액이면 원래 차감액과 동일)
   * @param description - 유저에게 보이는 설명 (예: "팩스 발송 실패 환불")
   */
  async restoreUsedMoney(
    mwlId: number,
    restoreAmount: number,
    description: string,
  ) {
    //* 원본 wallet 의 회원 번호는 바뀌지 않으므로 트랜잭션 밖에서 읽는다. 회원 잠금이 트랜잭션의 첫 질의다.
    const original = await this.moneyRepository.getWalletById(mwlId);
    if (!original) {
      throw new NotFoundException('머니 사용 내역을 찾을 수 없습니다.');
    }

    return await this.moneyRepository.runInTransaction(async (tx) => {
      const member = await this.moneyRepository.getMemberByIdForUpdate(
        original.memId,
        tx,
      );
      if (!member) {
        throw new NotFoundException('회원 정보를 찾을 수 없습니다.');
      }

      // 2. usage 조회 (어떤 source에서 얼마 차감됐는지)
      const usages = await this.moneyRepository.getUsagesByWalletId(mwlId, tx);
      if (!usages || usages.length === 0) {
        throw new BadRequestException('머니 사용 상세 내역이 없습니다.');
      }

      // 3. 부분 환불: FIFO 역순(마지막에 차감된 것부터)으로 restoreAmount만큼 복원
      let remaining = restoreAmount;
      const restoreList: { msrId: number; amount: number }[] = [];

      // usage를 역순으로 처리 (마지막 차감분부터 복원)
      const reversedUsages = [...usages].reverse();

      for (const usage of reversedUsages) {
        if (remaining <= 0) break;

        const restoreForThis = Math.min(remaining, usage.musAmount);
        restoreList.push({ msrId: usage.msrId, amount: restoreForThis });
        remaining -= restoreForThis;
      }

      // 4. source별 remaining 복원 (원천은 잠근 뒤 읽은 값에서 더한다)
      let basicRestore = 0;
      let purchaseRestore = 0;
      let agencyRestore = 0;
      const applied: typeof restoreList = [];

      for (const item of restoreList) {
        const source = await this.moneyRepository.getSourceByIdForUpdate(
          item.msrId,
          tx,
        );
        if (!source) continue;

        //* 전액 환불된 결제의 사용분은 PG 가 이미 돌려줬다. 더하면 같은 돈을 또 쓴다. 취소 중(CANCELLING)이면 결과를 모르니 더해 두고 결과 확인이 정리한다.
        if (source.mpyId) {
          const payment = await this.moneyRepository.getPaymentByIdForUpdate(
            source.mpyId,
            tx,
          );
          if (
            payment?.mpyStatus === MoneyPaymentStatus.CANCELLED &&
            payment.mpyCanceledAmount === payment.mpyMoney
          ) {
            this.logger.warn(
              `[사용 복구 제외] mwlId=${mwlId}, msrId=${item.msrId}, mpyId=${source.mpyId}, amount=${item.amount}`,
            );
            continue;
          }
        }
        applied.push(item);

        // source 잔액 복원
        const newRemaining = source.msrRemainingAmount + item.amount;
        await this.moneyRepository.updateSourceRemaining(
          item.msrId,
          newRemaining,
          tx,
        );

        // 타입별 복원 금액 집계
        if (source.msrMoneyType === MoneyMoneyType.BASIC) {
          basicRestore += item.amount;
        } else if (source.msrMoneyType === MoneyMoneyType.PURCHASE) {
          purchaseRestore += item.amount;
        } else if (source.msrMoneyType === MoneyMoneyType.AGENCY) {
          agencyRestore += item.amount;
        }
      }

      const restoredAmount = applied.reduce((sum, i) => sum + i.amount, 0);
      if (restoredAmount === 0) {
        return { success: true, restoredAmount: 0, mwlId: null };
      }

      // 5. 복원 wallet 이력 생성 (유저에게 보이는 1건)
      const newBalance =
        member.memMoney +
        member.memPurchaseMoney +
        member.memAgencyMoney +
        restoredAmount;
      const restoreMwlId = await this.moneyRepository.createWallet(
        {
          memId: original.memId,
          mwlType: MoneyWalletType.USE_CANCEL,
          mwlAmount: restoredAmount,
          mwlBalance: newBalance,
          mwlDescription: description,
        },
        tx,
      );

      // 6. 복원 usage 기록 (관리자가 source별 상세를 볼 수 있도록)
      for (const item of applied) {
        await this.moneyRepository.createUsage(
          {
            mwlId: restoreMwlId!,
            msrId: item.msrId,
            musType: MoneyUsageType.RESTORE,
            musAmount: item.amount,
          },
          tx,
        );
      }

      // 7. member 잔액 복원 (타입별 정확히)
      await this.moneyRepository.updateMemberMoney(
        original.memId,
        member.memMoney + basicRestore,
        member.memPurchaseMoney + purchaseRestore,
        member.memAgencyMoney + agencyRestore,
        tx,
      );

      return {
        success: true,
        restoredAmount,
        mwlId: restoreMwlId,
      };
    });
  }

  // ============================================================================
  // 스케줄러 - 머니 소멸
  // ============================================================================

  /**
   * 만료된 머니 소멸 처리 (스케줄러에서 호출)
   */
  /**
   * 만료 머니 소멸 — **스케줄러(SYSTEM) 행위다**(T4).
   *
   * 이 경로는 HTTP 를 타지 않아 T2 인터셉터가 0% 커버하고, 트랜잭션이 없어 T1 의 원자성도
   * 줄 수 없다. 그래서 stdout 감사 줄로만 남기며 **그 한계를 여기 명시한다** — '남는다' 와
   * '정본이다' 는 다르다.
   *
   * 실패는 더 이상 `console.error` 로 삼키지 않는다. 회원별 실패마다 감사 줄(FAILED)을 남기고
   * 실패 수를 돌려준다 — 호출한 스케줄러가 그것을 보고 알림을 띄운다. 이전에는 만료 처리가
   * 100% 실패해도 로그 한 줄 없이 정상 종료했다.
   */
  async expireMoneys() {
    const actor = systemActor('expired-money');
    const expiredSources = await this.moneyRepository.getExpiredSources();
    const memIds = [...new Set(expiredSources.map((s) => s.memId))];
    let processedCount = 0;
    let failedCount = 0;
    let skippedCount = 0;
    let expiredAmount = 0;

    for (const memId of memIds) {
      try {
        //* 회원마다 한 트랜잭션이다. 첫 질의는 회원 잠금이고, 원천은 잠근 뒤 다시 읽는다 (트랜잭션 밖 목록은 낡았을 수 있다).
        const outcome = await this.moneyRepository.runInTransaction(
          async (tx) => {
            const member = await this.moneyRepository.getMemberByIdForUpdate(
              memId,
              tx,
            );
            if (!member) {
              return {
                kind: 'skipped' as const,
                count: expiredSources.filter((s) => s.memId === memId).length,
              };
            }

            const expired =
              await this.moneyRepository.getExpiredSourcesByMemberForUpdate(
                memId,
                tx,
              );
            if (expired.length === 0) {
              return { kind: 'empty' as const };
            }

            //* 칸 일치 검사: 같은 종류 원천 잔여 합이 칸과 같아야 한다. 어긋나면 던져 이 회원을 롤백한다.
            //*  칸이 원천보다 작으면 차감이 칸을 넘어 음수가 되고, 크면 옛 값이 남은 것이다.
            const all = await this.moneyRepository.getSourcesByMemberForUpdate(
              memId,
              tx,
            );
            const sumOf = (type: string) =>
              all
                .filter((s) => s.msrMoneyType === type)
                .reduce((acc, s) => acc + s.msrRemainingAmount, 0);
            if (
              sumOf(MoneyMoneyType.BASIC) !== member.memMoney ||
              sumOf(MoneyMoneyType.AGENCY) !== member.memAgencyMoney
            ) {
              throw new Error(
                '칸 불일치: 같은 종류 원천 잔여 합과 회원 칸이 다르다',
              );
            }

            let memMoney = member.memMoney;
            let memAgencyMoney = member.memAgencyMoney;
            let total =
              member.memMoney + member.memPurchaseMoney + member.memAgencyMoney;
            const rows: {
              msrId: number;
              moneyType: string;
              amount: number;
              mwlId: number | undefined;
              balanceAfter: number;
            }[] = [];

            for (const source of expired) {
              const amount = source.msrRemainingAmount;
              if (source.msrMoneyType === MoneyMoneyType.BASIC) {
                memMoney -= amount;
              } else if (source.msrMoneyType === MoneyMoneyType.AGENCY) {
                memAgencyMoney -= amount;
              } else {
                throw new Error(
                  `알 수 없는 머니 종류 (${source.msrMoneyType})`,
                );
              }
              total -= amount;

              // 1. 원천 잔여를 0 으로
              await this.moneyRepository.updateSourceRemaining(
                source.msrId,
                0,
                tx,
              );

              // 2. wallet 이력 (EXPIRE) 과 usage
              const mwlId = await this.moneyRepository.createWallet(
                {
                  memId,
                  mwlType: MoneyWalletType.EXPIRE,
                  mwlAmount: -amount,
                  mwlBalance: total,
                  mwlDescription: '머니 소멸',
                },
                tx,
              );
              await this.moneyRepository.createUsage(
                {
                  mwlId: mwlId!,
                  msrId: source.msrId,
                  musType: MoneyUsageType.DEDUCT,
                  musAmount: amount,
                },
                tx,
              );

              rows.push({
                msrId: source.msrId,
                moneyType: source.msrMoneyType,
                amount,
                mwlId,
                balanceAfter: total,
              });
            }

            // 3. 회원 칸은 한 번만 쓴다
            await this.moneyRepository.updateMemberMoney(
              memId,
              memMoney,
              member.memPurchaseMoney,
              memAgencyMoney,
              tx,
            );

            return { kind: 'done' as const, rows };
          },
        );

        if (outcome.kind === 'skipped') {
          //* 회원 없는 원천은 건너뜀이다. 실패가 아니다 (PRD 완료 조건 5).
          skippedCount += outcome.count;
          continue;
        }
        if (outcome.kind === 'empty') continue;

        //* 감사 줄은 커밋 뒤에만 쓴다. 롤백된 변경의 줄이 남지 않게 한다.
        for (const row of outcome.rows) {
          processedCount++;
          expiredAmount += row.amount;
          this.audit.recordSystem({
            action: AUDIT_ACTION.moneyExpire,
            outcome: 'COMMITTED',
            actor,
            targetType: 'member',
            targetId: memId,
            amount: -row.amount,
            targetCount: 1,
            //* T4 — 소요 시간은 재지 않는다. `null` 이 정확한 관측이며 `0` 은 *빨랐다* 로 읽힌다(M2 D4b).
            txDurationMs: null,
            changes: {
              msrId: row.msrId,
              moneyType: row.moneyType,
              mwlId: row.mwlId,
              balanceAfter: row.balanceAfter,
            },
            reasonCode: 'EXPIRE',
            requestId: null,
            ip: null,
            userAgent: null,
          });
        }
      } catch (error) {
        failedCount++;
        const outstanding = expiredSources.filter((s) => s.memId === memId);
        //* 삼키지 않는다. 회원 단위로 롤백된 뒤 실패 줄을 남기고, 루프는 다음 회원으로 계속 돈다.
        //*  로그에는 회원 번호와 오류 메시지의 첫 줄만 싣는다 (복호화 값 · 원천 목록을 싣지 않는다).
        this.logger.error(
          `머니 소멸 처리 실패 (memId: ${memId}): ${error instanceof Error ? error.message.split('\n')[0] : String(error)}`,
        );
        this.audit.recordSystem({
          action: AUDIT_ACTION.moneyExpire,
          outcome: 'FAILED',
          actor,
          targetType: 'member',
          targetId: memId,
          amount: -outstanding.reduce(
            (acc, s) => acc + s.msrRemainingAmount,
            0,
          ),
          targetCount: 1,
          //* 같은 이유로 `null`(M2 D4b). 실패 경로도 트랜잭션 밖이다.
          txDurationMs: null,
          changes: { sourceCount: outstanding.length },
          reasonCode: 'EXPIRE_FAILED',
          requestId: null,
          ip: null,
          userAgent: null,
        });
      }
    }

    return { processedCount, failedCount, skippedCount, expiredAmount };
  }

  // ============================================================================
  // 구독 포인트 회수 (결제취소 시)
  // ============================================================================

  /**
   * 구독 포인트 적립 회수 (결제취소 시 호출)
   * @param memId 회원 ID
   * @param approvedAt 결제 승인 시간
   * @param pmtTid 결제 TID (로그용)
   */
  async recoverSubscriptionEarnedPoint(
    memId: number,
    approvedAt: string,
    pmtTid: string,
  ) {
    return await this.moneyRepository.runInTransaction(async (tx) => {
      //* 트랜잭션의 첫 질의는 회원 잠금이다. 적립 건 조회도 잠금 읽기로 뒤에 온다.
      const member = await this.moneyRepository.getMemberByIdForUpdate(
        memId,
        tx,
      );

      // 1. 해당 결제 시점에 적립된 구독 포인트 조회
      const earnWallets = await this.moneyRepository.findSubscriptionEarnWallet(
        memId,
        approvedAt,
        tx,
      );

      if (!earnWallets || earnWallets.length === 0) {
        return { success: true, recoveredAmount: 0 };
      }

      const earnWallet = earnWallets[0];
      const recoveryAmount = earnWallet.msrRemainingAmount;

      if (recoveryAmount <= 0) {
        return { success: true, recoveredAmount: 0 };
      }

      // 2. 회원 정보 확인
      if (!member) {
        return { success: false, recoveredAmount: 0 };
      }

      const newBalance =
        member.memMoney +
        member.memPurchaseMoney +
        member.memAgencyMoney -
        recoveryAmount;

      // 3. source 잔액 0으로 업데이트
      await this.moneyRepository.updateSourceRemaining(earnWallet.msrId, 0, tx);

      // 4. wallet 이력 생성 (EARN_RECOVERY)
      const mwlId = await this.moneyRepository.createWallet(
        {
          memId,
          mwlType: MoneyWalletType.EARN_RECOVERY,
          mwlAmount: -recoveryAmount,
          mwlBalance: newBalance,
          mwlDescription: `구독 포인트 회수 (결제취소: ${pmtTid})`,
        },
        tx,
      );

      // 5. usage 기록
      await this.moneyRepository.createUsage(
        {
          mwlId: mwlId!,
          msrId: earnWallet.msrId,
          musType: MoneyUsageType.DEDUCT,
          musAmount: recoveryAmount,
        },
        tx,
      );

      // 6. member 잔액 업데이트 (기본 머니에서 차감)
      await this.moneyRepository.updateMemberMoney(
        memId,
        Math.max(0, member.memMoney - recoveryAmount),
        member.memPurchaseMoney,
        member.memAgencyMoney,
        tx,
      );

      return { success: true, recoveredAmount: recoveryAmount };
    });
  }

  // ============================================================================
  // 관리자 - 회원머니 엑셀 양식 다운로드/업로드
  // ============================================================================

  /**
   * 회원머니 적립 엑셀 양식 다운로드
   */
  async downloadIssueForm(res: Response) {
    const dataGenerator = this.buildIssueFormSampleData();
    const columns = this.buildIssueFormExcelColumns();
    await this.excelService.generateUploadForm(
      res,
      { dataGenerator, columns },
      '회원머니 적립',
    );
  }

  /**
   * 회원머니 적립 엑셀 양식 샘플 데이터
   */
  private buildIssueFormSampleData() {
    return [
      {
        memberType: '기업',
        memLoginId: 'corp_user1',
        agcCode: 'SAMSUNG01',
        amount: 10000,
        description: '기업회원 이벤트 적립',
        reason: '2024년 12월 이벤트',
        expiryDays: '',
      },
      {
        memberType: '기업',
        memLoginId: 'corp_user2',
        agcCode: 'SAMSUNG01',
        amount: -5000,
        description: '기업회원 적립 회수',
        reason: '오적립 회수',
        expiryDays: '',
      },
      {
        memberType: '일반',
        memLoginId: 'general_user1',
        agcCode: '',
        amount: 10000,
        description: '일반회원 이벤트 적립',
        reason: '2024년 12월 이벤트',
        expiryDays: 30,
      },
      {
        memberType: '일반',
        memLoginId: 'general_user2',
        agcCode: '',
        amount: -5000,
        description: '일반회원 적립 회수',
        reason: '오적립 회수',
        expiryDays: '',
      },
    ];
  }

  /**
   * 회원머니 적립 엑셀 양식 컬럼
   */
  private buildIssueFormExcelColumns(): { header: string; key: string }[] {
    return [
      { header: '구분 (기업/일반)', key: 'memberType' },
      { header: '회원 아이디', key: 'memLoginId' },
      //* 동일 회원아이디가 여러 기업에 있어 기업회원은 기업코드로 특정(미입력 시 적립 거부). 일반회원은 비워둠.
      { header: '기업코드 (기업회원 필수)', key: 'agcCode' },
      { header: '금액 (양수:적립/음수:적립회수)', key: 'amount' },
      { header: '내용 (고객노출)', key: 'description' },
      { header: '사유 (관리자용)', key: 'reason' },
      { header: '유효기간 (일수, 적립시만)', key: 'expiryDays' },
    ];
  }

  /**
   * 회원머니 적립 엑셀 업로드
   */
  async uploadIssue(
    admId: number | null,
    file: Express.Multer.File,
    reasonInput: unknown,
    ctx: AuditContext,
    dryRun = false,
  ) {
    if (!file) {
      throw new BadRequestException('파일이 없습니다.');
    }

    const workbook = new Workbook();
    await workbook.xlsx.load(file.buffer);
    // 첫 번째 시트 사용 (시트명 검증 제거)
    const sheet = workbook.worksheets[0];

    if (!sheet) {
      throw new BadRequestException('시트를 찾을 수 없습니다.');
    }

    //* ★ 양식 검증 — 옛 양식(기업코드 컬럼 없는 6열)으로 업로드하면 3열부터 한 칸씩 밀려
    //*   금액이 엉뚱한 열에서 읽혀 잘못된 금액/회원에 적용될 수 있다(금전사고). 헤더로 강제 차단.
    const headerRow = sheet.getRow(1);
    //* ExcelJS .text = 표시 텍스트(리치텍스트/스타일 무관). Excel에서 저장한 파일은 헤더가
    //*   리치텍스트 객체로 바뀌어 .value.toString()이 "[object Object]"가 되므로 .text를 쓴다.
    const cellText = (cell: any) => String(cell?.text ?? '').trim();
    if (!cellText(headerRow.getCell(3)).includes('기업코드')) {
      throw new BadRequestException(
        '엑셀 양식이 올바르지 않습니다. 3번째 열이 "기업코드"여야 합니다. ' +
          '최신 양식을 다시 다운로드해 사용해주세요. (옛 양식 업로드 방지)',
      );
    }

    const rows: Array<{
      rowNum: number;
      memberType: string;
      memLoginId: string;
      agcCode: string;
      amount: number;
      description: string;
      reason: string;
      expiryDays: number | null;
      member?: any;
    }> = [];
    const validationErrors: string[] = [];

    sheet.eachRow((row, rowNumber) => {
      if (rowNumber === 1) return; // 헤더 스킵

      //* 컬럼: ①구분 ②회원아이디 ③기업코드(기업회원 필수) ④금액 ⑤내용 ⑥사유 ⑦유효기간
      //*   Excel 저장 파일의 리치텍스트 대응 위해 문자열은 .text로 읽는다.
      const memberType = cellText(row.getCell(1));
      const memLoginId = cellText(row.getCell(2));
      const agcCode = cellText(row.getCell(3));
      const amountText = cellText(row.getCell(4)).replace(/,/g, '');
      const description = cellText(row.getCell(5));
      const reason = cellText(row.getCell(6));
      const expiryText = cellText(row.getCell(7)).replace(/,/g, '');
      const hasAnyValue = [
        memberType,
        memLoginId,
        agcCode,
        amountText,
        description,
        reason,
        expiryText,
      ].some(Boolean);

      if (!hasAnyValue) {
        return;
      }

      const errorCountBeforeRow = validationErrors.length;
      if (memberType !== '기업' && memberType !== '일반') {
        validationErrors.push(
          `${rowNumber}행: 구분은 기업 또는 일반으로 입력해주세요.`,
        );
      }
      if (!memLoginId) {
        validationErrors.push(`${rowNumber}행: 회원 아이디를 입력해주세요.`);
      }
      if (!amountText) {
        validationErrors.push(`${rowNumber}행: 금액을 입력해주세요.`);
      }

      const amount = Number(amountText);
      if (
        amountText &&
        (!Number.isFinite(amount) || !Number.isInteger(amount) || amount === 0)
      ) {
        validationErrors.push(
          `${rowNumber}행: 금액은 0이 아닌 정수로 입력해주세요.`,
        );
      } else if (Math.abs(amount) > MAX_MONEY_CHANGE_AMOUNT) {
        validationErrors.push(
          `${rowNumber}행: 금액은 2,147,483,647원 이하로 입력해주세요.`,
        );
      }

      const expiryDays = expiryText ? Number(expiryText) : null;
      const hasInvalidExpiryDays =
        expiryDays !== null &&
        (!Number.isFinite(expiryDays) ||
          !Number.isInteger(expiryDays) ||
          expiryDays <= 0);
      if (hasInvalidExpiryDays) {
        validationErrors.push(
          `${rowNumber}행: 유효기간은 1일 이상의 정수로 입력해주세요.`,
        );
      }

      if (validationErrors.length === errorCountBeforeRow) {
        rows.push({
          rowNum: rowNumber,
          memberType,
          memLoginId,
          agcCode,
          amount,
          description,
          reason,
          expiryDays,
        });
      }
    });

    if (validationErrors.length > 0) {
      throw new BadRequestException(validationErrors.join('\n'));
    }

    if (rows.length === 0) {
      throw new BadRequestException('처리할 데이터가 없습니다.');
    }

    // ========== 1단계: 사전 검증 (트랜잭션 외부) ==========
    for (const row of rows) {
      const isCorp = row.memberType === '기업';

      // 적립회수(음수)인데 유효기간이 설정된 경우
      if (row.amount < 0 && row.expiryDays !== null) {
        validationErrors.push(
          `${row.rowNum}행: 적립회수 시 유효기간을 설정할 수 없습니다.`,
        );
        continue;
      }

      // 회원 존재 확인
      //* 기업회원은 동일 회원아이디가 여러 기업에 존재 → 기업코드로 특정(미입력 시 거부, 오배정 방지).
      //*   일반회원은 기업개념이 없어 회원아이디로 조회.
      let member;
      if (isCorp) {
        if (!row.agcCode) {
          validationErrors.push(
            `${row.rowNum}행: 기업회원은 기업코드가 필수입니다. (회원: ${row.memLoginId})`,
          );
          continue;
        }
        member = await this.moneyRepository.getMemberByLoginIdAndAgcCode(
          row.memLoginId,
          row.agcCode,
        );
        if (!member) {
          validationErrors.push(
            `${row.rowNum}행: 회원아이디(${row.memLoginId}) + 기업코드(${row.agcCode}) 조합의 기업회원을 찾을 수 없습니다.`,
          );
          continue;
        }
      } else {
        member = await this.moneyRepository.getMemberByLoginId(row.memLoginId);
        if (!member) {
          validationErrors.push(
            `${row.rowNum}행: 회원(${row.memLoginId})을 찾을 수 없습니다.`,
          );
          continue;
        }
      }
      row.member = member;

      // 적립회수 시 잔액 확인
      if (row.amount < 0 && member.memMoney < Math.abs(row.amount)) {
        validationErrors.push(
          `${row.rowNum}행: 회원(${row.memLoginId})의 잔액이 부족합니다. (잔액: ${member.memMoney.toLocaleString()}원)`,
        );
      }

      if (row.amount > 0) {
        const basicBalance = member.memMoney + row.amount;
        const totalBalance =
          member.memMoney +
          member.memPurchaseMoney +
          member.memAgencyMoney +
          row.amount;
        if (basicBalance > MAX_MONEY_BALANCE_AMOUNT) {
          validationErrors.push(
            `${row.rowNum}행: 회원(${row.memLoginId})의 기본머니 잔액이 4,294,967,295원을 초과합니다.`,
          );
        }
        if (totalBalance > MAX_MONEY_BALANCE_AMOUNT) {
          validationErrors.push(
            `${row.rowNum}행: 회원(${row.memLoginId})의 전체 머니 잔액이 4,294,967,295원을 초과합니다.`,
          );
        }
      }
    }

    // 검증 실패 시 즉시 에러 반환
    if (validationErrors.length > 0) {
      throw new BadRequestException(validationErrors.join('\n'));
    }

    //* 사유 유형은 업로드당 하나다 — 감사 행이 업로드당 1행이다(계획 M8 D4). '기타' 면 모든 행의
    //*  사유 칸이 차 있어야 한다. 미리보기 분기 **앞**이라 확인 모달 전에 거절이 보인다.
    const reasonCode = assertInterventionReason(
      reasonInput,
      rows.map((r) => r.reason),
      rows.map((r) => r.rowNum + '행'),
    );

    //* ★ 미리보기(dryRun): 검증·회원매칭까지만 하고 적용은 안 한다.
    //*   화면에서 "이 명단/금액 맞나요?" 모달로 사람이 확인 후 실제 업로드(dryRun=false)하게 해 오적용을 막는다.
    //*   적용과 100% 동일한 파싱·검증 경로를 타므로 미리보기 통과 = 적용 성공이 보장된다.
    if (dryRun) {
      let totalEarn = 0;
      let totalRecovery = 0;
      const preview = rows.map((row) => {
        if (row.amount > 0) totalEarn += row.amount;
        else totalRecovery += Math.abs(row.amount);
        return {
          rowNum: row.rowNum,
          memberType: row.memberType,
          memLoginId: row.memLoginId,
          agcCode: row.agcCode || null,
          agcName: row.member?.agcName ?? null,
          memName: row.member?.memName ?? null,
          currentBalance: row.member?.memMoney ?? 0,
          amount: row.amount,
          type: row.amount > 0 ? '적립' : '회수',
          afterBalance: (row.member?.memMoney ?? 0) + row.amount,
          expiryDays: row.expiryDays,
        };
      });
      return {
        dryRun: true,
        summary: {
          count: rows.length,
          totalEarn,
          totalRecovery,
          netAmount: totalEarn - totalRecovery,
        },
        rows: preview,
      };
    }

    // ========== 2단계: 트랜잭션으로 일괄 처리 ==========
    let pending: MoneyAuditFields | null = null;
    //* 시각은 트랜잭션 **밖**에서 잡는다(계획 M2 D4a). 안에서 재면 커밋 대기가 빠져
    //*  락 보유시간의 상한이 되지 못한다 — 상한이 아니면 M3 가 그 값으로 판단할 수 없다.
    const txStartedAt = process.hrtime.bigint();
    const result = await this.moneyRepository.runInTransaction(async (tx) => {
      let corpSuccess = 0;
      let generalSuccess = 0;
      //* 행마다 만든 적립 배치 id 를 모은다 — 이것이 감사 줄과 업무 배치를 잇는 유일한
      //*  연결선이다. 범위(min/max)는 근거가 안 된다: `mni_id` 는 전역 AUTO_INCREMENT 라
      //*  동시 업로드의 id 가 교차 배정돼 BETWEEN 이 남의 지급까지 쓸어 온다(계획 D4).
      const mniIds: number[] = [];
      const balances: { memId: number; before: number; after: number }[] = [];

      for (const row of rows) {
        const member = row.member;
        const isCorp = row.memberType === '기업';

        const mniId = await this.moneyRepository.createIssue(
          {
            admId,
            agcId: isCorp && member.agcId ? member.agcId : undefined,
            mniType:
              row.amount > 0
                ? MoneyIssueType.EARN
                : MoneyIssueType.EARN_RECOVERY,
            mniTargetType: MoneyTargetType.SPECIFIC,
            mniMemberType: isCorp
              ? MoneyMemberType.CORP
              : MoneyMemberType.GENERAL,
            mniTitle: row.description || '머니 적립',
            mniReason: row.reason || null,
            mniAmount: Math.abs(row.amount),
            mniMemberCount: 1,
            mniTotalAmount: Math.abs(row.amount),
            mniHasExpiry: row.expiryDays && row.expiryDays > 0 ? 1 : 0,
            mniExpiryDays:
              row.expiryDays && row.expiryDays > 0 ? row.expiryDays : undefined,
          },
          tx,
        );
        if (mniId) mniIds.push(mniId);

        if (row.amount > 0) {
          balances.push({
            memId: member.memId,
            ...(await this.earnMoney(
              member.memId,
              mniId!,
              row.amount,
              row.description || '머니 적립',
              row.expiryDays && row.expiryDays > 0 ? 1 : 0,
              row.expiryDays ?? undefined,
              tx,
            )),
          });
        } else {
          balances.push({
            memId: member.memId,
            ...(await this.recoverEarnMoney(
              member.memId,
              Math.abs(row.amount),
              row.description || '머니 적립회수',
              tx,
            )),
          });
        }

        await this.moneyRepository.createIssueTarget(
          {
            mniId: mniId!,
            memId: member.memId,
            mitAmount: Math.abs(row.amount),
          },
          tx,
        );

        if (isCorp) corpSuccess++;
        else generalSuccess++;
      }

      pending = {
        targetType: 'excel',
        targetId: null,
        //* 엑셀은 행마다 금액이 달라 단가가 없다 — 합계를 싣는다.
        amount: rows.reduce((sum, r) => sum + r.amount, 0),
        targetCount: rows.length,
        reasonCode,
        changes: {
          corpSuccess,
          generalSuccess,
          fileRows: rows.length,
          ...memberMoneyChanges(balances),
        },
        //* `changes` **밖** 전용 키다 — 안에 두면 상한 초과 시 객체 전체가 절단 봉투로
        //*  치환되어 `_idsTruncated` 같은 절단 표시까지 함께 사라진다(계획 D4·D6).
        //*  기대 건수를 함께 넘긴다: 행마다 배치를 하나씩 만들므로 `rows.length` 가 기대치이고,
        //*  저장소가 id 를 안 돌려준 행은 `_idsMissing` 으로 드러난다.
        batch: batchIds(mniIds, rows.length),
      };
      await this.appendMoneyAudit(
        tx,
        ctx,
        AUDIT_ACTION.moneyIssueUpload,
        pending,
      );

      return {
        success: rows.length,
        failed: 0,
        corpSuccess,
        corpFailed: 0,
        generalSuccess,
        generalFailed: 0,
      };
    });
    this.recordMoneyAudit(
      ctx,
      AUDIT_ACTION.moneyIssueUpload,
      pending,
      msSince(txStartedAt),
    );
    return result;
  }

  // ============================================================================
  // 기업회원머니 엑셀 양식 다운로드/업로드
  // ============================================================================

  /**
   * 기업회원머니 지급 엑셀 양식 다운로드
   */
  async downloadGrantForm(res: Response, scope?: MoneyGrantScope) {
    const dataGenerator = this.buildGrantFormSampleData();
    const columns = this.buildGrantFormExcelColumns();
    const workbook = new Workbook();
    const worksheet = workbook.addWorksheet('기업회원 머니 지급');
    worksheet.columns = columns.map((column) => ({
      ...column,
      width: Math.max(String(column.header).length + 4, 18),
    }));
    dataGenerator.forEach((data) => worksheet.addRow(data));
    worksheet.getRow(1).font = { bold: true };
    worksheet.views = [{ state: 'frozen', ySplit: 1 }];

    if (scope) {
      const memberRows =
        await this.moneyRepository.getAgencyMemberReferenceRows(
          scope.agcId,
          scope.orgPath,
        );
      if (memberRows.length > 0) {
        const memberSheet = workbook.addWorksheet('회원목록');
        memberSheet.columns = [
          { header: '회원아이디', key: 'memLoginId', width: 20 },
          { header: '회원명', key: 'memName', width: 20 },
          { header: '소속조직코드', key: 'orgCode', width: 18 },
          { header: '소속조직명', key: 'orgName', width: 28 },
          { header: '현재 기업머니', key: 'memAgencyMoney', width: 18 },
        ];
        memberRows.forEach((row) => memberSheet.addRow(row));
        memberSheet.getRow(1).font = { bold: true };
        memberSheet.views = [{ state: 'frozen', ySplit: 1 }];

        const memberIdRange = `'회원목록'!$A$2:$A$${Math.max(
          memberSheet.rowCount,
          2,
        )}`;
        for (let rowNumber = 2; rowNumber <= 10000; rowNumber += 1) {
          worksheet.getCell(`A${rowNumber}`).dataValidation = {
            type: 'list',
            allowBlank: false,
            formulae: [memberIdRange],
            showErrorMessage: true,
            errorTitle: '회원아이디 오류',
            error: '회원목록에 있는 회원아이디만 선택할 수 있습니다.',
          };
          worksheet.getCell(`B${rowNumber}`).dataValidation = {
            type: 'whole',
            operator: 'notEqual',
            formulae: [0],
            showErrorMessage: true,
            errorTitle: '금액 오류',
            error: '0이 아닌 정수 금액을 입력해주세요.',
          };
          worksheet.getCell(`E${rowNumber}`).dataValidation = {
            type: 'whole',
            operator: 'greaterThan',
            formulae: [0],
            allowBlank: true,
            showErrorMessage: true,
            errorTitle: '유효기간 오류',
            error: '유효기간은 지급 건에만 1 이상의 정수로 입력해주세요.',
          };
        }
      }
    }

    await workbook.xlsx.write(res);
    res.end();
  }

  /**
   * 기업회원머니 지급 엑셀 양식 샘플 데이터
   */
  private buildGrantFormSampleData() {
    return [
      {
        memLoginId: 'corp_user1',
        amount: 10000,
        description: '이벤트 지급',
        reason: '2024년 12월 이벤트',
        expiryDays: 30,
      },
      {
        memLoginId: 'corp_user2',
        amount: -5000,
        description: '지급 취소',
        reason: '오지급 회수',
        expiryDays: '',
      },
    ];
  }

  /**
   * 기업회원머니 지급 엑셀 양식 컬럼
   */
  private buildGrantFormExcelColumns(): { header: string; key: string }[] {
    return [
      { header: '회원 아이디', key: 'memLoginId' },
      { header: '금액 (양수:지급/음수:회수)', key: 'amount' },
      { header: '내용 (고객노출)', key: 'description' },
      { header: '사유 (관리자용)', key: 'reason' },
      { header: '유효기간 (일수, 지급시만)', key: 'expiryDays' },
    ];
  }

  /**
   * 기업회원머니 지급 엑셀 업로드
   */
  async uploadGrant(
    admId: number | null,
    file: Express.Multer.File,
    agcId: number,
    reasonInput: unknown,
    ctx: AuditContext,
    scope?: MoneyGrantScope,
  ) {
    if (!file) {
      throw new BadRequestException('파일이 없습니다.');
    }
    if (scope) {
      agcId = scope.agcId;
    }

    if (!agcId) {
      throw new BadRequestException('기업 ID가 필요합니다.');
    }

    // 기업 정보 확인
    const agency = await this.moneyRepository.getAgencyById(agcId);
    if (!agency) {
      throw new NotFoundException('기업 정보를 찾을 수 없습니다.');
    }

    const workbook = new Workbook();
    await workbook.xlsx.load(file.buffer);

    // 첫 번째 시트 사용 (시트명 검증 제거)
    const sheet = workbook.worksheets[0];

    if (!sheet) {
      throw new BadRequestException('엑셀 파일에 시트가 없습니다.');
    }

    const rows: Array<{
      rowNum: number;
      memLoginId: string;
      amount: number;
      description: string;
      reason: string;
      expiryDays: number | null;
      member?: any;
    }> = [];
    const validationErrors: string[] = [];

    const cellText = (cell: any) => String(cell?.text ?? '').trim();
    sheet.eachRow((row, rowNumber) => {
      if (rowNumber === 1) return; // 헤더 스킵

      const memLoginId = cellText(row.getCell(1));
      const amountText = cellText(row.getCell(2)).replace(/,/g, '');
      const amount = Number(amountText);
      const description = cellText(row.getCell(3));
      const reason = cellText(row.getCell(4));
      const expiryText = cellText(row.getCell(5)).replace(/,/g, '');
      const expiryDays = expiryText ? Number(expiryText) : null;
      const hasAnyValue =
        !!memLoginId ||
        !!amountText ||
        !!description ||
        !!reason ||
        !!expiryText;

      if (!hasAnyValue) return;
      if (!memLoginId) {
        validationErrors.push(`${rowNumber}행: 회원 아이디를 입력해주세요.`);
        return;
      }
      if (!amountText) {
        validationErrors.push(`${rowNumber}행: 금액을 입력해주세요.`);
        return;
      }
      if (
        !Number.isFinite(amount) ||
        !Number.isInteger(amount) ||
        amount === 0
      ) {
        validationErrors.push(
          `${rowNumber}행: 금액은 0이 아닌 정수로 입력해주세요.`,
        );
        return;
      }

      rows.push({
        rowNum: rowNumber,
        memLoginId,
        amount,
        description,
        reason,
        expiryDays,
      });
    });

    if (rows.length === 0) {
      if (validationErrors.length > 0) {
        throw new BadRequestException(validationErrors.join('\n'));
      }
      throw new BadRequestException('처리할 데이터가 없습니다.');
    }

    // ========== 1단계: 사전 검증 (트랜잭션 외부) ==========
    // 지급 총액 계산 (양수만)
    const totalGrantAmount = rows
      .filter((row) => row.amount > 0)
      .reduce((sum, row) => sum + row.amount, 0);
    assertMoneyChangeTotal(totalGrantAmount, '총 지급머니');

    // 기업머니 잔액 확인 (지급 총액)
    if (totalGrantAmount > 0 && agency.agcMoney < totalGrantAmount) {
      throw new BadRequestException(
        `기업머니가 부족합니다. (잔액: ${agency.agcMoney.toLocaleString()}원, 필요: ${totalGrantAmount.toLocaleString()}원)`,
      );
    }

    // 각 행별 검증
    for (const row of rows) {
      assertIntegerMoneyAmount(row.amount, `${row.rowNum}행 금액`);
      if (
        row.expiryDays !== null &&
        (!Number.isInteger(row.expiryDays) || row.expiryDays <= 0)
      ) {
        validationErrors.push(
          `${row.rowNum}행: 유효기간은 1 이상의 정수로 입력해주세요.`,
        );
        continue;
      }
      if (row.amount < 0 && row.expiryDays && row.expiryDays > 0) {
        validationErrors.push(
          `${row.rowNum}행: 회수 시 유효기간을 설정할 수 없습니다.`,
        );
        continue;
      }

      // 해당 기업 소속 회원인지 확인
      const member = await this.moneyRepository.getMemberByLoginIdAndAgcId(
        row.memLoginId,
        agcId,
      );
      if (!member) {
        validationErrors.push(
          `${row.rowNum}행: 회원(${row.memLoginId})은 해당 기업 소속이 아닙니다.`,
        );
        continue;
      }
      if (scope?.orgPath) {
        const inScope = await this.moneyRepository.isMemberInAgencyScope(
          member.memId,
          agcId,
          scope.orgPath,
        );
        if (!inScope) {
          validationErrors.push(
            `${row.rowNum}행: 회원(${row.memLoginId})은 관리 조직 범위 밖 회원입니다.`,
          );
          continue;
        }
      }
      row.member = member;

      // 회수(음수) 시: 회원의 기업머니 잔액 확인
      if (row.amount < 0 && member.memAgencyMoney < Math.abs(row.amount)) {
        validationErrors.push(
          `${row.rowNum}행: 회원(${row.memLoginId})의 기업머니가 부족합니다. (잔액: ${member.memAgencyMoney.toLocaleString()}원)`,
        );
      }
    }

    if (validationErrors.length > 0) {
      throw new BadRequestException(validationErrors.join('\n'));
    }

    //* 사유 유형은 업로드당 하나다(계획 M8 D4) — uploadIssue 와 같은 규칙.
    //*  기업·조직관리자(scope)는 고르지 않는다 — 행들의 부호로 배분/회수/배분·회수를 정한다.
    const reasonCode = scope
      ? corpGrantReason(
          rows.some((r) => r.amount > 0),
          rows.some((r) => r.amount < 0),
        )
      : assertInterventionReason(
          reasonInput,
          rows.map((r) => r.reason),
          rows.map((r) => r.rowNum + '행'),
        );

    // ========== 2단계: 트랜잭션으로 일괄 처리 ==========
    let pending: MoneyAuditFields | null = null;
    //* 시각은 트랜잭션 **밖**에서 잡는다(계획 M2 D4a). 안에서 재면 커밋 대기가 빠져
    //*  락 보유시간의 상한이 되지 못한다 — 상한이 아니면 M3 가 그 값으로 판단할 수 없다.
    const txStartedAt = process.hrtime.bigint();
    const result = await this.moneyRepository.runInTransaction(async (tx) => {
      const actor = this.getMoneyActor(scope, ctx);
      //* 행마다 만든 지급 배치 id 를 모은다(계획 D4 — 범위는 연결 근거가 아니다).
      const mngIds: number[] = [];
      const lockedAgency = await this.moneyRepository.getAgencyByIdForUpdate(
        agcId,
        tx,
      );
      if (!lockedAgency) {
        throw new NotFoundException('기업 정보를 찾을 수 없습니다.');
      }
      if (totalGrantAmount > 0 && lockedAgency.agcMoney < totalGrantAmount) {
        throw new BadRequestException(
          `기업머니가 부족합니다. (잔액: ${lockedAgency.agcMoney.toLocaleString()}원, 필요: ${totalGrantAmount.toLocaleString()}원)`,
        );
      }

      let success = 0;
      let corpBalance = lockedAgency.agcMoney;

      for (const row of rows) {
        const isGrant = row.amount > 0;
        const amount = Math.abs(row.amount);
        const title =
          row.description || (isGrant ? '머니 지급' : '머니 지급회수');
        const hasExpiry = isGrant && row.expiryDays ? 1 : 0;
        const mngId = await this.moneyRepository.createGrant(
          {
            agcId,
            admId,
            mngActorType: actor.actorType,
            mngActorMemId: actor.actorMemId,
            mngType: isGrant
              ? MoneyGrantType.GRANT
              : MoneyGrantType.GRANT_RECOVERY,
            mngTargetType: MoneyTargetType.SPECIFIC,
            mngTitle: title,
            mngReason: row.reason || null,
            mngAmount: amount,
            mngMemberCount: 1,
            mngTotalAmount: amount,
            mngHasExpiry: hasExpiry,
            mngExpiryDays: hasExpiry ? row.expiryDays! : null,
          },
          tx,
        );
        if (mngId) mngIds.push(mngId);

        if (isGrant) {
          await this.grantMoney(
            row.member.memId,
            agcId,
            mngId!,
            amount,
            title,
            hasExpiry,
            row.expiryDays ?? undefined,
            tx,
          );
        } else {
          await this.recoverGrantMoney(
            row.member.memId,
            agcId,
            mngId!,
            amount,
            title,
            tx,
          );
        }

        await this.moneyRepository.createGrantTarget(
          {
            mngId: mngId!,
            memId: row.member.memId,
            mgtAmount: amount,
          },
          tx,
        );

        const corpChange = isGrant ? -amount : amount;
        corpBalance += corpChange;
        assertMoneyBalance(corpBalance, '기업머니 잔액');
        await this.moneyRepository.createCorpHistory(
          {
            agcId,
            admId,
            mngId: mngId!,
            mcpActorType: actor.actorType,
            mcpActorMemId: actor.actorMemId,
            mcpType: isGrant ? 'GRANT' : 'GRANT_RECOVERY',
            mcpTitle: title,
            mcpReason: row.reason || null,
            mcpAmount: corpChange,
            mcpBalance: corpBalance,
          },
          tx,
        );
        success++;
      }

      await this.moneyRepository.updateAgencyMoney(agcId, corpBalance, tx);

      pending = {
        targetType: 'agency',
        targetId: agcId,
        amount: rows.reduce((sum, r) => sum + r.amount, 0),
        targetCount: success,
        reasonCode,
        changes: {
          fileRows: rows.length,
          moneyActorType: actor.actorType,
          corpBalanceBefore: lockedAgency.agcMoney,
          corpBalanceAfter: corpBalance,
        },
        //* `changes` **밖** 전용 키다(계획 D4·D6) — 위 uploadIssue 와 같은 사유.
        //*  기대치는 `success` 다: 같은 루프에서 행마다 배치를 만들고 `success++` 를 하므로
        //*  둘이 갈리면 id 를 못 받은 행이 있다는 뜻이다.
        batch: batchIds(mngIds, success),
      };
      await this.appendMoneyAudit(
        tx,
        ctx,
        AUDIT_ACTION.moneyGrantUpload,
        pending,
      );

      return { success, failed: 0 };
    });
    this.recordMoneyAudit(
      ctx,
      AUDIT_ACTION.moneyGrantUpload,
      pending,
      msSince(txStartedAt),
    );
    return result;
  }

  // ============================================================================
  // 스마트로 PG 결제 취소
  // ============================================================================

  /**
   * 스마트로 일반결제 취소 API 호출
   */
  private async cancelGeneralPayment(params: {
    tid: string;
    cancelReason: string;
    amount: number;
    isPartialCancel?: boolean;
  }) {
    return this.smartroService.cancelGeneralPayment(params);
  }
}
