import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { getFirestore } from 'firebase-admin/firestore'; import { bootAdmin, shutdownAdmin, wipeUser } from './support.js'; import { grantPop, reconcileUser, refundPop, refundSplit, spendSplit, type PopType, } from '../src/ledger/ledger.js'; /** * 겸용 사용처 분할 차감 — 정책 원장 B-06 · 설계서 §3.2. * 일반(activity) 우선, 부족분만 기여(support). 두 재화면 원장 2건 · 한 트랜잭션 · 멱등키 하나. */ const UID = 'test-ledger-split'; const db = () => getFirestore(); beforeAll(() => { bootAdmin(); }); afterAll(async () => { await shutdownAdmin(); }); beforeEach(async () => { await wipeUser(UID); await db().recursiveDelete(db().doc('fandoms/f-split')); }); async function seed(general: number, contribution: number) { if (general > 0) { await grantPop({ uid: UID, popType: 'activity', amount: general, reasonCode: 'ADMIN_GRANT', idempotencyKey: 'seed:g' }); } if (contribution > 0) { await grantPop({ uid: UID, popType: 'support', amount: contribution, reasonCode: 'IAP_PURCHASE', idempotencyKey: 'seed:c' }); } } async function wallet() { const w = await db().doc(`users/${UID}/wallet/main`).get(); return { general: w.get('activityBalance') as number, contribution: w.get('supportBalance') as number }; } async function spendRows() { const snap = await db().collection(`users/${UID}/popTransactions`).where('type', '==', 'spend').get(); return snap.docs.map((d) => ({ popType: d.get('popType') as PopType, amount: d.get('amount') as number })) .sort((a, b) => a.popType.localeCompare(b.popType)); } const spend = (amount: number, key = 'fee:1') => spendSplit({ uid: UID, amount, reasonCode: 'CAMPAIGN_FEE', idempotencyKey: key }); describe('spendSplit', () => { it('일반 잔액이 딱 맞으면 일반 1건만', async () => { await seed(100, 50); const parts = await spend(100); expect(parts.map((p) => [p.popType, p.amount])).toEqual([['activity', 100]]); expect(await wallet()).toEqual({ general: 0, contribution: 50 }); expect(await spendRows()).toEqual([{ popType: 'activity', amount: -100 }]); }); it('일반만 있으면 일반에서', async () => { await seed(300, 0); await spend(120); expect((await wallet()).general).toBe(180); expect(await spendRows()).toEqual([{ popType: 'activity', amount: -120 }]); }); it('기여만 있으면 기여에서', async () => { await seed(0, 300); const parts = await spend(120); expect(parts.map((p) => p.popType)).toEqual(['support']); expect((await wallet()).contribution).toBe(180); expect(await spendRows()).toEqual([{ popType: 'support', amount: -120 }]); }); it('섞이면 일반을 다 쓰고 부족분만 기여 — 원장 2건 · 같은 멱등키 · lotOrder 규범', async () => { await seed(30, 200); const parts = await spend(100); expect(parts.map((p) => [p.popType, p.amount, p.balanceAfter])) .toEqual([['activity', 30, 0], ['support', 70, 130]]); expect(await wallet()).toEqual({ general: 0, contribution: 130 }); expect(await spendRows()).toEqual([ { popType: 'activity', amount: -30 }, { popType: 'support', amount: -70 }, ]); const rows = await db().collection(`users/${UID}/popTransactions`).where('type', '==', 'spend').get(); for (const r of rows.docs) { expect(r.get('idempotencyKey')).toBe('fee:1'); expect(r.get('lotOrder')).toBe('general-first-fifo'); } expect((await reconcileUser(UID)).ok).toBe(true); }); it('합계가 모자라면 E-POP-01 이고 원장 행이 없다', async () => { await seed(30, 20); await expect(spend(100)).rejects.toMatchObject({ details: { code: 'E-POP-01' } }); expect(await spendRows()).toEqual([]); expect(await wallet()).toEqual({ general: 30, contribution: 20 }); }); it('같은 멱등키 재시도는 어느 행도 두 번 쓰지 않는다(동시 포함)', async () => { await seed(30, 200); const first = await spend(100); const [again, ...racing] = await Promise.all([spend(100), spend(100), spend(100)]); expect(again!.map((p) => p.txId).sort()).toEqual(first.map((p) => p.txId).sort()); for (const r of racing) expect(r.length).toBe(2); expect(await spendRows()).toHaveLength(2); expect(await wallet()).toEqual({ general: 0, contribution: 130 }); }); it('기여 POP 으로 낸 예치 · 등록비는 성장 · FPI 원천이 아니다(C-30)', async () => { await db().doc(`users/${UID}`).set({ fandomId: 'f-split', primaryFandomId: 'f-split' }); await seed(0, 500); await spend(100, 'fee:growth'); await spendSplit({ uid: UID, amount: 100, reasonCode: 'QNA_BOUNTY', idempotencyKey: 'bounty:growth' }); expect((await db().collection(`users/${UID}/growthEvents`).get()).size).toBe(0); expect((await db().collection('fandoms/f-split/fpiEvents').get()).size).toBe(0); expect((await db().doc('fandoms/f-split').get()).get('growthPop')).toBeUndefined(); }); }); describe('lot 부족', () => { it('기여 몫의 lot 이 모자라면 E-POP-02 · 행 0건 · 지갑은 트랜잭션 밖에서 mismatch 표시', async () => { await seed(30, 200); const lot = (await db().collection(`users/${UID}/popLots`).where('popType', '==', 'support').get()).docs[0]!; await lot.ref.update({ remaining: 10 }); await expect(spend(100)).rejects.toMatchObject({ details: { code: 'E-POP-02' } }); expect(await spendRows()).toEqual([]); const w = await db().doc(`users/${UID}/wallet/main`).get(); expect(w.get('reconcileStatus')).toBe('mismatch'); expect(w.get('activityBalance')).toBe(30); const general = (await db().collection(`users/${UID}/popLots`).where('popType', '==', 'activity').get()).docs[0]!; expect(general.get('remaining')).toBe(30); }); }); describe('refundSplit', () => { it('섞인 차감을 행마다 원래 타입으로 돌리고 재시도는 한 번뿐이다', async () => { await seed(30, 200); const parts = await spend(100); const refund = () => refundSplit({ uid: UID, originalTxIds: parts.map((p) => p.txId), idempotencyKey: 'feeRefund:1', reasonCode: 'CAMPAIGN_REFUND', }); const first = await refund(); const again = await refund(); expect(again.map((r) => r.txId).sort()).toEqual(first.map((r) => r.txId).sort()); expect(await wallet()).toEqual({ general: 30, contribution: 200 }); const refunds = await db().collection(`users/${UID}/popTransactions`).where('type', '==', 'refund').get(); expect(refunds.docs.map((d) => [d.get('popType'), d.get('amount')]).sort()) .toEqual([['activity', 30], ['support', 70]]); for (const p of parts) { expect((await db().doc(`users/${UID}/popTransactions/${p.txId}`).get()).get('refundedBy')).toBeTruthy(); } expect((await reconcileUser(UID)).ok).toBe(true); }); it('이미 다른 경로로 환불된 행은 그 환불을 쓰고, 같은 원거래 ID 는 한 번만 센다', async () => { await seed(30, 200); const parts = await spend(100); const [general, contribution] = parts.map((p) => p.txId); const earlier = await refundPop({ uid: UID, originalTxId: contribution!, idempotencyKey: 'other-path', reasonCode: 'CAMPAIGN_REFUND', }); const results = await refundSplit({ uid: UID, originalTxIds: [general!, contribution!, general!], idempotencyKey: 'feeRefund:reuse', reasonCode: 'CAMPAIGN_FEE_REFUND', }); expect(results).toHaveLength(2); expect(results[1]!.txId).toBe(earlier.txId); expect(await wallet()).toEqual({ general: 30, contribution: 200 }); const refunds = await db().collection(`users/${UID}/popTransactions`).where('type', '==', 'refund').get(); expect(refunds.size).toBe(2); expect((await reconcileUser(UID)).ok).toBe(true); }); it('QNA_BOUNTY 분할 예치 → 환불 → 대사가 맞는다(누적 기여도 0 으로 돌아온다)', async () => { await seed(30, 200); const parts = await spendSplit({ uid: UID, amount: 100, reasonCode: 'QNA_BOUNTY', idempotencyKey: 'bounty:mixed' }); expect((await db().doc(`users/${UID}`).get()).get('totalContributedPop')).toBe(100); expect((await reconcileUser(UID)).ok).toBe(true); await refundSplit({ uid: UID, originalTxIds: parts.map((p) => p.txId), idempotencyKey: 'qnarefund:mixed', reasonCode: 'QNA_REFUND', }); expect((await db().doc(`users/${UID}`).get()).get('totalContributedPop')).toBe(0); expect(await wallet()).toEqual({ general: 30, contribution: 200 }); expect((await reconcileUser(UID)).ok).toBe(true); }); it('원거래 하나라도 없으면 아무 행도 쓰지 않는다', async () => { await seed(30, 200); const parts = await spend(100); await expect(refundSplit({ uid: UID, originalTxIds: [parts[0]!.txId, 'missing'], idempotencyKey: 'feeRefund:bad', reasonCode: 'CAMPAIGN_REFUND', })).rejects.toMatchObject({ details: { code: 'E-POP-02' } }); expect((await db().collection(`users/${UID}/popTransactions`).where('type', '==', 'refund').get()).size).toBe(0); expect(await wallet()).toEqual({ general: 0, contribution: 130 }); }); });