196 lines
9.0 KiB
TypeScript
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 });
|
|
});
|
|
});
|