Files
fanuzu/app/firebase/functions/test/ledger_split.test.ts

196 lines
9.0 KiB
TypeScript

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