import { AUDIT_REASON } from '@api/modules/audit/audit-reason';
import { DrizzleService } from '@lib/drizzle/src';
import { ConfigService } from '@nestjs/config';
import { sql } from 'drizzle-orm';
import { MoneyRepository } from './money.repository';
import { MoneyService } from './money.service';

/**
 * 머니 소멸 · 사용 · 충전 · 적립 · 지급 · 환불 복구의 겹침과 롤백 — **격리 MySQL 에서 실제로 돈다.** plan M12 Task 1 · 2.
 *
 * 실행: gates.sh `itspec` 이 mysql:8.0.42 를 띄우고 `DB_POOL_LIMIT=4` 로 jest 를 돌린다. 풀이 둘 이상이어야
 * 잠금을 쥔 트랜잭션이 같은 풀의 다음 질의를 기다리는 교착을 피한다.
 * 스키마는 이 시험의 질의가 읽고 쓰는 열만 최소 DDL 로 만든다 (server-itspec-run-recipe).
 * 저장소는 실제 것이다. 롤백 주입과 멈춤 지점은 저장소 인스턴스의 메서드를 감싸 만든다.
 */
const CONN = {
  database: process.env.IT_MYSQL_DATABASE,
  host: '127.0.0.1',
  user: process.env.IT_MYSQL_USER,
  port: Number(process.env.IT_MYSQL_PORT),
  password: process.env.IT_MYSQL_PASSWORD,
};
jest.setTimeout(120000);

const drizzle = new DrizzleService({
  get: (key: string) =>
    ({
      'db.database': CONN.database,
      'db.host': CONN.host,
      'db.user': CONN.user,
      'db.port': CONN.port,
      'db.password': CONN.password,
    })[key],
} as unknown as ConfigService);
const repo = new MoneyRepository(drizzle, {
  get: (key: string) => (key === 'aes.secret' ? 'it-aes-key' : undefined),
} as unknown as ConfigService);

//* 감사 어댑터는 줄을 모으지 않는다. 금전 감사의 형태는 다른 시험이 본다.
const noopAudit = new Proxy({} as Record<string, unknown>, {
  get: () => async () => undefined,
});
const service = new MoneyService(
  repo,
  {} as any,
  {} as any,
  {} as any,
  noopAudit as any,
);

const raw = (q: string) => drizzle.execute(sql.raw(q));
const rowsOf = async <T = any>(q: string): Promise<T[]> =>
  ((await raw(q)) as unknown as [T[]])[0];
const insertRow = async (q: string): Promise<number> =>
  ((await raw(q)) as unknown as [{ insertId: number }])[0].insertId;
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

const TABLES = [
  'tn_agency',
  'tn_member',
  'tn_money_source',
  'th_money_wallet',
  'th_money_usage',
  'th_money_corp',
  'tn_money_grant',
  'tn_money_grant_target',
];

beforeAll(async () => {
  await raw(`CREATE TABLE IF NOT EXISTS tn_agency (
    agc_id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
    sag_id INT NULL, sag_code VARCHAR(20) NULL, sac_code VARCHAR(20) NULL,
    agc_registration_number VARCHAR(12) NOT NULL,
    agc_corporate_name VARCHAR(30) NOT NULL,
    agc_corporate_code VARCHAR(20) NOT NULL,
    agc_representative_name VARCHAR(20) NULL,
    agc_money INT UNSIGNED NOT NULL DEFAULT 0,
    agc_deliquent TINYINT NOT NULL DEFAULT 0,
    agc_analysis_view TINYINT NOT NULL DEFAULT 0,
    agc_login_id VARCHAR(50) NULL, agc_password VARCHAR(255) NULL,
    agc_created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    agc_updated_at DATETIME NULL)`);
  await raw(`CREATE TABLE IF NOT EXISTS tn_member (
    mem_id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
    agc_id INT UNSIGNED NULL,
    mem_type VARCHAR(15) NOT NULL, mem_name VARCHAR(128) NOT NULL,
    mem_login_id VARCHAR(20) NOT NULL, mem_password VARCHAR(100) NOT NULL,
    mem_email VARCHAR(128) NULL, mem_phone VARCHAR(128) NOT NULL,
    mem_money INT UNSIGNED NOT NULL DEFAULT 0,
    mem_purchase_money INT UNSIGNED NOT NULL DEFAULT 0,
    mem_agency_money INT UNSIGNED NOT NULL DEFAULT 0,
    mem_created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP)`);
  await raw(`CREATE TABLE IF NOT EXISTS th_money_wallet (
    mwl_id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
    mem_id INT UNSIGNED NOT NULL, mwl_type VARCHAR(20) NOT NULL,
    mwl_amount INT NOT NULL, mwl_balance INT UNSIGNED NOT NULL,
    mwl_description VARCHAR(255) NULL,
    mwl_created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP)`);
  await raw(`CREATE TABLE IF NOT EXISTS tn_money_source (
    msr_id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
    mem_id INT UNSIGNED NOT NULL, mwl_id INT UNSIGNED NOT NULL,
    msr_type VARCHAR(20) NOT NULL, msr_money_type VARCHAR(20) NOT NULL,
    msr_original_amount INT UNSIGNED NOT NULL, msr_remaining_amount INT UNSIGNED NOT NULL,
    mni_id INT UNSIGNED NULL, mng_id INT UNSIGNED NULL, mpy_id INT UNSIGNED NULL,
    msr_expired_at DATETIME NULL,
    msr_created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP)`);
  //* 사용 · 환불 차감이 취소 예약 중인 결제를 본다 (NOT EXISTS · 부족 사유). 이 시험에는 결제 행이 없다.
  await raw(`CREATE TABLE IF NOT EXISTS tn_money_payment (
    mpy_id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
    mem_id INT UNSIGNED NOT NULL,
    mpy_status VARCHAR(20) NOT NULL DEFAULT 'COMPLETED')`);
  await raw(`CREATE TABLE IF NOT EXISTS th_money_usage (
    mus_id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
    mwl_id INT UNSIGNED NOT NULL, msr_id INT UNSIGNED NOT NULL,
    mus_type VARCHAR(20) NOT NULL, mus_amount INT UNSIGNED NOT NULL,
    mus_created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP)`);
  await raw(`CREATE TABLE IF NOT EXISTS th_money_corp (
    mcp_id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
    agc_id INT UNSIGNED NOT NULL, adm_id INT UNSIGNED NULL, mng_id INT UNSIGNED NULL,
    mcp_actor_type VARCHAR(20) NULL, mcp_actor_mem_id INT UNSIGNED NULL,
    mcp_type VARCHAR(20) NOT NULL, mcp_title VARCHAR(100) NOT NULL,
    mcp_reason VARCHAR(255) NULL, mcp_amount INT NOT NULL, mcp_balance INT UNSIGNED NOT NULL,
    mcp_created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP)`);
  await raw(`CREATE TABLE IF NOT EXISTS tn_money_grant (
    mng_id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
    agc_id INT UNSIGNED NOT NULL, adm_id INT UNSIGNED NULL,
    mng_actor_type VARCHAR(20) NULL, mng_actor_mem_id INT UNSIGNED NULL,
    mng_type VARCHAR(20) NOT NULL, mng_target_type VARCHAR(20) NOT NULL,
    mng_title VARCHAR(100) NOT NULL, mng_reason VARCHAR(255) NULL,
    mng_amount INT UNSIGNED NOT NULL,
    mng_member_count INT UNSIGNED NOT NULL DEFAULT 0,
    mng_total_amount INT UNSIGNED NOT NULL DEFAULT 0,
    mng_has_expiry TINYINT NOT NULL DEFAULT 0, mng_expiry_days INT UNSIGNED NULL,
    mng_created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP)`);
  await raw(`CREATE TABLE IF NOT EXISTS tn_money_grant_target (
    mgt_id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
    mng_id INT UNSIGNED NOT NULL, mem_id INT UNSIGNED NOT NULL,
    mgt_amount INT UNSIGNED NOT NULL,
    mgt_created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP)`);
});

beforeEach(async () => {
  for (const t of TABLES) await raw(`TRUNCATE TABLE ${t}`);
  await raw(`INSERT INTO tn_agency (agc_id, agc_registration_number, agc_corporate_name, agc_corporate_code, agc_money)
    VALUES (1, '1234567890', 'it', 'IT-1', 10000)`);
});

afterEach(() => {
  //* 감싼 저장소 메서드는 시험마다 원래대로 돌린다.
  for (const k of [
    'createUsage',
    'updateMemberMoney',
    'getSourcesByMemberForUpdate',
    'getAgencyById',
    'getMemberByIdForUpdate',
    'updateSourceRemaining',
  ]) {
    delete (repo as any)[k];
  }
});

/** 회원과 원천을 시드한다. 칸은 원천 잔여 합과 같아야 한다 (칸 불일치를 만드는 시험만 예외). */
async function seed(
  memId: number,
  cells: {
    memMoney?: number;
    memPurchaseMoney?: number;
    memAgencyMoney?: number;
  },
  sources: {
    type: 'EARN' | 'GRANT' | 'CHARGE';
    money: 'BASIC' | 'PURCHASE' | 'AGENCY';
    amount: number;
    expiry: 'NONE' | 'PAST' | 'FUTURE';
  }[],
) {
  await raw(`INSERT INTO tn_member (mem_id, agc_id, mem_type, mem_name, mem_login_id, mem_password, mem_phone, mem_money, mem_purchase_money, mem_agency_money)
    VALUES (${memId}, 1, 'GENERAL', 'x', 'id${memId}', 'x', 'x', ${cells.memMoney ?? 0}, ${cells.memPurchaseMoney ?? 0}, ${cells.memAgencyMoney ?? 0})`);
  for (const s of sources) {
    const wallet =
      await insertRow(`INSERT INTO th_money_wallet (mem_id, mwl_type, mwl_amount, mwl_balance, mwl_description)
      VALUES (${memId}, 'EARN', ${s.amount}, 0, 'seed')`);
    const expiry =
      s.expiry === 'PAST'
        ? 'DATE_SUB(NOW(), INTERVAL 1 DAY)'
        : s.expiry === 'FUTURE'
          ? 'DATE_ADD(NOW(), INTERVAL 30 DAY)'
          : 'NULL';
    await raw(`INSERT INTO tn_money_source (mem_id, mwl_id, msr_type, msr_money_type, msr_original_amount, msr_remaining_amount, msr_expired_at)
      VALUES (${memId}, ${wallet}, '${s.type}', '${s.money}', ${s.amount}, ${s.amount}, ${expiry})`);
  }
}

/** 회원 칸 합과 원천 잔여 합, 그리고 칸 세 개와 원천 잔여 (종류별). */
async function state(memId: number) {
  const [m] = await rowsOf<any>(
    `SELECT mem_money AS memMoney, mem_purchase_money AS memPurchaseMoney, mem_agency_money AS memAgencyMoney FROM tn_member WHERE mem_id = ${memId}`,
  );
  const [s] = await rowsOf<any>(
    `SELECT COALESCE(SUM(msr_remaining_amount), 0) AS total FROM tn_money_source WHERE mem_id = ${memId}`,
  );
  const sources = await rowsOf<any>(
    `SELECT msr_id AS msrId, msr_money_type AS moneyType, msr_remaining_amount AS remaining, msr_type AS type FROM tn_money_source WHERE mem_id = ${memId} ORDER BY msr_id`,
  );
  const [w] = await rowsOf<any>(
    `SELECT COUNT(*) AS n FROM th_money_wallet WHERE mem_id = ${memId}`,
  );
  return {
    cells:
      Number(m.memMoney) +
      Number(m.memPurchaseMoney) +
      Number(m.memAgencyMoney),
    raw: m,
    sourceTotal: Number(s.total),
    sources,
    walletCount: Number(w.n),
  };
}

/** 머니 불변식: 칸 합 = 원천 잔여 합. 겹침 시험은 이 식을 본다. */
async function expectInvariant(memId: number) {
  const st = await state(memId);
  expect(st.cells).toBe(st.sourceTotal);
  return st;
}

const EXPIRE_CHANGE_ROWS =
  'tn_member,tn_money_source,th_money_wallet,th_money_usage';

/** 지정한 저장소 메서드를 한 번 감싸 멈춤 지점을 만든다. */
function gate() {
  let reachedResolve!: () => void;
  let releaseResolve!: () => void;
  const reached = new Promise<void>((r) => (reachedResolve = r));
  const release = new Promise<void>((r) => (releaseResolve = r));
  return { reached, release, arrive: reachedResolve, open: releaseResolve };
}

describe('머니 소멸 · 사용 · 충전 · 적립 · 지급 · 환불 복구 (격리 MySQL)', () => {
  it('[MS12-itspec-1] 사용 차감은 만료 원천을 빼고 나머지를 현행 순서로 차감한다', async () => {
    //* 기본 머니 칸 5000 = 만료 2000 + 유효 3000, 충전 원천 4000 (purchase 칸).
    await seed(1, { memMoney: 5000, memPurchaseMoney: 4000 }, [
      { type: 'EARN', money: 'BASIC', amount: 2000, expiry: 'PAST' },
      { type: 'EARN', money: 'BASIC', amount: 3000, expiry: 'NONE' },
      { type: 'CHARGE', money: 'PURCHASE', amount: 4000, expiry: 'NONE' },
    ]);
    await service.useMoney(1, 3500, '사용 시험');
    const st = await expectInvariant(1);
    const byId = Object.fromEntries(
      st.sources.map((s: any) => [s.msrId, s.remaining]),
    );
    expect(byId[1]).toBe(2000);
    expect(byId[2]).toBe(0);
    expect(byId[3]).toBe(3500);
    expect(st.raw.memMoney).toBe(2000);
    expect(st.raw.memPurchaseMoney).toBe(3500);
  });

  it('[MS12-itspec-2] 소멸 중간에 던지면 그 회원의 원천 · 이력 · 칸이 시드 값 그대로이고, 다음 실행에서 다시 처리된다', async () => {
    await seed(1, { memMoney: 2000 }, [
      { type: 'EARN', money: 'BASIC', amount: 2000, expiry: 'PAST' },
    ]);
    await seed(2, { memMoney: 3000 }, [
      { type: 'EARN', money: 'BASIC', amount: 3000, expiry: 'PAST' },
    ]);
    const before1 = await state(1);
    let fail = true;
    const orig = repo.createUsage.bind(repo);
    (repo as any).createUsage = async (data: any, db?: any) => {
      if (fail && data.msrId === 1) throw new Error('INJECT');
      return orig(data, db);
    };
    const r1 = await service.expireMoneys();
    expect(r1).toMatchObject({ failedCount: 1, processedCount: 1 });
    const after1 = await state(1);
    expect(after1.sources).toEqual(before1.sources);
    expect(after1.raw.memMoney).toBe(2000);
    expect(after1.walletCount).toBe(before1.walletCount);
    fail = false;
    const r2 = await service.expireMoneys();
    expect(r2).toMatchObject({ failedCount: 0, processedCount: 1 });
    await expectInvariant(1);
  });

  it('[MS12-itspec-3] 소멸 × 사용을 같은 회원에 동시에 부르기를 20회 반복해도 칸 합 = 원천 잔여 합이다', async () => {
    for (let i = 0; i < 20; i++) {
      await raw(`DELETE FROM tn_money_source`);
      await raw(`DELETE FROM th_money_wallet`);
      await raw(`DELETE FROM tn_member`);
      await seed(1, { memMoney: 8000 }, [
        { type: 'EARN', money: 'BASIC', amount: 5000, expiry: 'PAST' },
        { type: 'EARN', money: 'BASIC', amount: 3000, expiry: 'FUTURE' },
      ]);
      await Promise.allSettled([
        service.expireMoneys(),
        service.useMoney(1, 1000, '겹침'),
      ]);
      await expectInvariant(1);
    }
  });

  it('[MS12-itspec-4] 소멸 × 충전을 같은 회원에 동시에 부르기를 20회 반복해도 칸 합 = 원천 잔여 합이다', async () => {
    for (let i = 0; i < 20; i++) {
      await raw(`DELETE FROM tn_money_source`);
      await raw(`DELETE FROM th_money_wallet`);
      await raw(`DELETE FROM tn_member`);
      await seed(1, { memMoney: 5000 }, [
        { type: 'EARN', money: 'BASIC', amount: 5000, expiry: 'PAST' },
      ]);
      await Promise.allSettled([
        service.expireMoneys(),
        (service as any).chargeMoney(1, 0, 1000, '충전'),
      ]);
      await expectInvariant(1);
    }
  });

  it('[MS12-itspec-5] 칸이 같은 종류 원천 합과 다르면 그 회원은 실패로 롤백되고, 이어지는 사용은 유효 원천만 쓴다', async () => {
    await seed(1, { memMoney: 6000 }, [
      { type: 'EARN', money: 'BASIC', amount: 5000, expiry: 'PAST' },
      { type: 'EARN', money: 'BASIC', amount: 3000, expiry: 'FUTURE' },
    ]);
    const before = await state(1);
    const r = await service.expireMoneys();
    expect(r).toMatchObject({ failedCount: 1, processedCount: 0 });
    const after = await state(1);
    expect(after.sources).toEqual(before.sources);
    expect(after.raw.memMoney).toBe(6000);
    expect(after.walletCount).toBe(before.walletCount);
    await service.useMoney(1, 3000, '사용');
    const st = await state(1);
    expect(st.raw.memMoney).toBe(3000);
    expect(st.sources.find((s: any) => s.msrId === 2).remaining).toBe(0);
    expect(st.sources.find((s: any) => s.msrId === 1).remaining).toBe(5000);
  });

  it('[MS12-itspec-6] 소멸을 두 번 돌리면 두 번째는 처리 0 이고 행의 값이 바뀌지 않는다', async () => {
    await seed(1, { memMoney: 5000, memPurchaseMoney: 1000 }, [
      { type: 'EARN', money: 'BASIC', amount: 5000, expiry: 'PAST' },
      { type: 'CHARGE', money: 'PURCHASE', amount: 1000, expiry: 'NONE' },
    ]);
    await service.expireMoneys();
    const once = await state(1);
    const r2 = await service.expireMoneys();
    expect(r2).toMatchObject({ processedCount: 0, failedCount: 0 });
    const twice = await state(1);
    expect(twice.sources).toEqual(once.sources);
    expect(twice.raw).toEqual(once.raw);
  });

  it('[MS12-itspec-7] 소멸 뒤에도 만료 기한이 없는 충전 원천의 잔여는 그대로다', async () => {
    await seed(1, { memMoney: 2000, memPurchaseMoney: 4000 }, [
      { type: 'EARN', money: 'BASIC', amount: 2000, expiry: 'PAST' },
      { type: 'CHARGE', money: 'PURCHASE', amount: 4000, expiry: 'NONE' },
    ]);
    await service.expireMoneys();
    const st = await expectInvariant(1);
    expect(st.sources.find((s: any) => s.type === 'CHARGE').remaining).toBe(
      4000,
    );
    expect(st.raw.memPurchaseMoney).toBe(4000);
  });

  it('[MS12-itspec-8] 만료 원천만 남은 회원의 사용은 머니 부족으로 던지고 원천 · 칸 · 이력이 바뀌지 않는다', async () => {
    await seed(1, { memMoney: 3000 }, [
      { type: 'EARN', money: 'BASIC', amount: 3000, expiry: 'PAST' },
    ]);
    const before = await state(1);
    await expect(service.useMoney(1, 1000, '사용')).rejects.toThrow(
      '머니가 부족합니다',
    );
    const after = await state(1);
    expect(after.sources).toEqual(before.sources);
    expect(after.raw).toEqual(before.raw);
    expect(after.walletCount).toBe(before.walletCount);
  });

  it('[MS12-itspec-9] 충전 중 회원 칸 쓰기가 던지면 충전 원천 · 이력 · 칸이 시드 값 그대로이고 호출은 던진다', async () => {
    await seed(1, { memMoney: 0, memPurchaseMoney: 0 }, []);
    const before = await state(1);
    const orig = repo.updateMemberMoney.bind(repo);
    (repo as any).updateMemberMoney = async (...a: any[]) => {
      throw new Error('INJECT');
    };
    await expect(
      (service as any).chargeMoney(1, 0, 1000, '충전'),
    ).rejects.toThrow('INJECT');
    (repo as any).updateMemberMoney = orig;
    const after = await state(1);
    expect(after.sources).toEqual(before.sources);
    expect(after.raw).toEqual(before.raw);
    expect(after.walletCount).toBe(before.walletCount);
  });

  it('[MS12-itspec-10] 사용 중 이력 쓰기가 던지면 (원천 차감은 이미 써진 뒤다) 원천 · 칸 · 이력이 시드 값 그대로이다', async () => {
    await seed(1, { memMoney: 3000 }, [
      { type: 'EARN', money: 'BASIC', amount: 3000, expiry: 'FUTURE' },
    ]);
    const before = await state(1);
    const orig = repo.createUsage.bind(repo);
    (repo as any).createUsage = async () => {
      throw new Error('INJECT');
    };
    await expect(service.useMoney(1, 1000, '사용')).rejects.toThrow('INJECT');
    (repo as any).createUsage = orig;
    const after = await state(1);
    expect(after.sources).toEqual(before.sources);
    expect(after.raw).toEqual(before.raw);
    expect(after.walletCount).toBe(before.walletCount);
  });

  it('[MS12-itspec-11] 소멸이 회원 잠금을 쥔 채 원천을 읽는 동안 시스템 적립은 기다리고, 두 호출 뒤 칸 합 = 원천 잔여 합이다', async () => {
    for (let i = 0; i < 20; i++) {
      await raw(`DELETE FROM tn_money_source`);
      await raw(`DELETE FROM th_money_wallet`);
      await raw(`DELETE FROM tn_member`);
      await seed(1, { memMoney: 8000 }, [
        { type: 'EARN', money: 'BASIC', amount: 5000, expiry: 'PAST' },
        { type: 'EARN', money: 'BASIC', amount: 3000, expiry: 'FUTURE' },
      ]);
      const g = gate();
      let armed = true;
      const orig = repo.getSourcesByMemberForUpdate.bind(repo);
      (repo as any).getSourcesByMemberForUpdate = async (
        memId: number,
        db?: any,
      ) => {
        const r = await orig(memId, db);
        if (armed) {
          armed = false;
          g.arrive();
          await g.release;
        }
        return r;
      };
      const expiring = service.expireMoneys();
      await g.reached;
      let earned = false;
      const earning = service
        .earnMoneyBySystem(1, 1000, '적립', false)
        .then(() => {
          earned = true;
        });
      await sleep(200);
      expect(earned).toBe(false);
      g.open();
      await Promise.all([expiring, earning]);
      delete (repo as any).getSourcesByMemberForUpdate;
      const st = await expectInvariant(1);
      expect(st.sourceTotal).toBe(4000);
    }
  });

  it('[MS12-itspec-12] 회원 없는 만료 원천은 건너뜀으로 세고 실패로 세지 않으며, 잔여는 그대로다', async () => {
    await raw(`INSERT INTO tn_money_source (mem_id, mwl_id, msr_type, msr_money_type, msr_original_amount, msr_remaining_amount, msr_expired_at)
      VALUES (99999, 0, 'EARN', 'BASIC', 500, 500, DATE_SUB(NOW(), INTERVAL 1 DAY))`);
    const r = await service.expireMoneys();
    expect(r).toMatchObject({
      skippedCount: 1,
      failedCount: 0,
      processedCount: 0,
    });
    const [s] = await rowsOf<any>(
      `SELECT msr_remaining_amount AS remaining FROM tn_money_source WHERE mem_id = 99999`,
    );
    expect(Number(s.remaining)).toBe(500);
  });

  it('[MS12-itspec-13] 회수는 현행 FIFO 로 만료 원천부터 빼고, 이어지는 소멸 뒤에도 칸 = 원천 합이다', async () => {
    await seed(1, { memMoney: 8000 }, [
      { type: 'EARN', money: 'BASIC', amount: 5000, expiry: 'PAST' },
      { type: 'EARN', money: 'BASIC', amount: 3000, expiry: 'FUTURE' },
    ]);
    await repo.runInTransaction(async (tx) =>
      (service as any).recoverEarnMoney(1, 3000, '회수', tx),
    );
    const mid = await state(1);
    const byId = Object.fromEntries(
      mid.sources.map((s: any) => [s.msrId, s.remaining]),
    );
    expect(byId[1]).toBe(2000);
    expect(byId[2]).toBe(3000);
    expect(mid.raw.memMoney).toBe(5000);
    await service.expireMoneys();
    const end = await expectInvariant(1);
    expect(end.raw.memMoney).toBe(3000);
  });

  it('[MS12-itspec-14] 지급 · 지급 회수가 소멸을 기다린 뒤 최신 칸을 읽는다 (옛 스냅샷 덮어쓰기 회귀)', async () => {
    //* 지급: 회원 잠금 전에 트랜잭션 안에서 기업 행 일반 읽기로 스냅샷을 잡는다. 그 뒤 멈추고, 소멸이 커밋하게 둔다.
    for (const direction of [1, -1] as const) {
      await raw(`DELETE FROM tn_money_source`);
      await raw(`DELETE FROM th_money_wallet`);
      await raw(`DELETE FROM tn_member`);
      await raw(`UPDATE tn_agency SET agc_money = 10000 WHERE agc_id = 1`);
      await seed(1, { memAgencyMoney: 8000 }, [
        { type: 'GRANT', money: 'AGENCY', amount: 5000, expiry: 'PAST' },
        { type: 'GRANT', money: 'AGENCY', amount: 3000, expiry: 'FUTURE' },
      ]);
      const g = gate();
      let armed = true;
      const orig = repo.getAgencyById.bind(repo);
      (repo as any).getAgencyById = async (agcId: number, db?: any) => {
        const r = await orig(agcId, db);
        if (armed && db !== undefined) {
          armed = false;
          g.arrive();
          await g.release;
        }
        return r;
      };
      const reason = AUDIT_REASON.find((r) => !r.detailRequired)!.code;
      const dto = {
        memId: 1,
        agcId: 1,
        mngAmount: 1000 * direction,
        mngHasExpiry: false,
        mngExpiryDays: undefined,
        mngTitle: '시험',
        mngReason: null,
        reasonCode: reason,
      } as any;
      const granting = service.createGrantSingle(1, dto, {
        actor: { role: 'ADMIN' },
        requestId: null,
        ip: null,
        userAgent: null,
      } as any);
      await g.reached;
      await service.expireMoneys();
      const mid = await state(1);
      expect(mid.raw.memAgencyMoney).toBe(3000);
      g.open();
      await granting;
      delete (repo as any).getAgencyById;
      const st = await expectInvariant(1);
      expect(st.raw.memAgencyMoney).toBe(direction === 1 ? 4000 : 2000);
    }
  });

  it('[MS12-itspec-15] 환불 복구 (사용 취소) 가 회원 잠금을 기다리는 동안 소멸이 끼어도 최종 칸 합 = 원천 잔여 합이다', async () => {
    await seed(1, { memMoney: 7000 }, [
      { type: 'EARN', money: 'BASIC', amount: 5000, expiry: 'PAST' },
      { type: 'EARN', money: 'BASIC', amount: 2000, expiry: 'FUTURE' },
    ]);
    //* 유효 원천에서 1000 이 이미 쓰였다 (사용 이력 한 건).
    await raw(
      `UPDATE tn_money_source SET msr_remaining_amount = 1000 WHERE msr_id = 2`,
    );
    await raw(`UPDATE tn_member SET mem_money = 6000 WHERE mem_id = 1`);
    const useWallet =
      await insertRow(`INSERT INTO th_money_wallet (mem_id, mwl_type, mwl_amount, mwl_balance, mwl_description)
      VALUES (1, 'USE', -1000, 6000, '사용')`);
    await raw(
      `INSERT INTO th_money_usage (mwl_id, msr_id, mus_type, mus_amount) VALUES (${useWallet}, 2, 'DEDUCT', 1000)`,
    );
    //* 칸 6000 = 원천 합 (5000 + 1000) 이다.

    const g = gate();
    let armed = true;
    const orig = repo.getMemberByIdForUpdate.bind(repo);
    (repo as any).getMemberByIdForUpdate = async (memId: number, db?: any) => {
      const r = await orig(memId, db);
      if (armed && db !== undefined) {
        armed = false;
        g.arrive();
        await g.release;
      }
      return r;
    };
    const restoring = service.restoreUsedMoney(useWallet, 1000, '사용 취소');
    await g.reached;
    const expiring = service.expireMoneys();
    await sleep(200);
    g.open();
    await Promise.all([restoring, expiring]);
    delete (repo as any).getMemberByIdForUpdate;
    const st = await expectInvariant(1);
    expect(st.sourceTotal).toBe(2000);
    void EXPIRE_CHANGE_ROWS;
  });
});
