重构
This commit is contained in:
78
apps/api/src/domains/ledger/funds-posting.service.spec.ts
Normal file
78
apps/api/src/domains/ledger/funds-posting.service.spec.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { FundsPostingService } from './funds-posting.service';
|
||||
|
||||
describe('FundsPostingService', () => {
|
||||
const wallet = {
|
||||
deposit: jest.fn(),
|
||||
freezeForBet: jest.fn(),
|
||||
settleBet: jest.fn(),
|
||||
applyResettleDelta: jest.fn(),
|
||||
};
|
||||
let service: FundsPostingService;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
service = new FundsPostingService(wallet as never);
|
||||
});
|
||||
|
||||
it('freezes bet funds with a stable business key', async () => {
|
||||
await service.freezeBet({
|
||||
userId: 1n,
|
||||
stake: new Decimal(50),
|
||||
betNo: 'BET-001',
|
||||
tx: {} as never,
|
||||
});
|
||||
|
||||
expect(wallet.freezeForBet).toHaveBeenCalledWith(
|
||||
1n,
|
||||
expect.any(Decimal),
|
||||
'BET-001',
|
||||
expect.anything(),
|
||||
'bet:BET-001:freeze',
|
||||
);
|
||||
});
|
||||
|
||||
it('settles bets with batch and bet identity in the business key', async () => {
|
||||
await service.settleBet({
|
||||
userId: 2n,
|
||||
stake: new Decimal(100),
|
||||
payout: new Decimal(180),
|
||||
betNo: 'BET-002',
|
||||
batchNo: 'SETTLE-001',
|
||||
result: 'WIN',
|
||||
tx: {} as never,
|
||||
});
|
||||
|
||||
expect(wallet.settleBet).toHaveBeenCalledWith(
|
||||
2n,
|
||||
expect.any(Decimal),
|
||||
expect.any(Decimal),
|
||||
'BET-002',
|
||||
'WIN',
|
||||
expect.anything(),
|
||||
'settle:SETTLE-001:BET-002',
|
||||
);
|
||||
});
|
||||
|
||||
it('passes caller-provided business keys for transfer postings', async () => {
|
||||
await service.deposit({
|
||||
userId: 3n,
|
||||
amount: 25,
|
||||
operatorId: 9n,
|
||||
referenceId: 'REQ-1',
|
||||
transactionType: 'ADMIN_DEPOSIT',
|
||||
businessKey: 'admin-deposit:9:REQ-1',
|
||||
});
|
||||
|
||||
expect(wallet.deposit).toHaveBeenCalledWith(
|
||||
3n,
|
||||
25,
|
||||
9n,
|
||||
undefined,
|
||||
'REQ-1',
|
||||
'ADMIN_DEPOSIT',
|
||||
undefined,
|
||||
'admin-deposit:9:REQ-1',
|
||||
);
|
||||
});
|
||||
});
|
||||
124
apps/api/src/domains/ledger/funds-posting.service.ts
Normal file
124
apps/api/src/domains/ledger/funds-posting.service.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { WalletService } from './wallet.service';
|
||||
|
||||
type TxClient = Prisma.TransactionClient;
|
||||
|
||||
@Injectable()
|
||||
export class FundsPostingService {
|
||||
constructor(private wallet: WalletService) {}
|
||||
|
||||
deposit(command: {
|
||||
userId: bigint;
|
||||
amount: Decimal | number;
|
||||
operatorId: bigint;
|
||||
remark?: string;
|
||||
referenceId?: string;
|
||||
transactionType?: string;
|
||||
businessKey?: string;
|
||||
tx?: TxClient;
|
||||
}) {
|
||||
return this.wallet.deposit(
|
||||
command.userId,
|
||||
command.amount,
|
||||
command.operatorId,
|
||||
command.remark,
|
||||
command.referenceId,
|
||||
command.transactionType ?? 'MANUAL_DEPOSIT',
|
||||
command.tx,
|
||||
command.businessKey,
|
||||
);
|
||||
}
|
||||
|
||||
withdraw(command: {
|
||||
userId: bigint;
|
||||
amount: Decimal | number;
|
||||
operatorId: bigint;
|
||||
remark?: string;
|
||||
referenceId?: string;
|
||||
transactionType?: string;
|
||||
businessKey?: string;
|
||||
tx?: TxClient;
|
||||
}) {
|
||||
return this.wallet.withdraw(
|
||||
command.userId,
|
||||
command.amount,
|
||||
command.operatorId,
|
||||
command.remark,
|
||||
command.referenceId,
|
||||
command.transactionType ?? 'MANUAL_WITHDRAW',
|
||||
command.tx,
|
||||
command.businessKey,
|
||||
);
|
||||
}
|
||||
|
||||
freezeBet(command: {
|
||||
userId: bigint;
|
||||
stake: Decimal | number;
|
||||
betNo: string;
|
||||
tx?: TxClient;
|
||||
}) {
|
||||
return this.wallet.freezeForBet(
|
||||
command.userId,
|
||||
command.stake,
|
||||
command.betNo,
|
||||
command.tx,
|
||||
`bet:${command.betNo}:freeze`,
|
||||
);
|
||||
}
|
||||
|
||||
settleBet(command: {
|
||||
userId: bigint;
|
||||
stake: Decimal;
|
||||
payout: Decimal;
|
||||
betNo: string;
|
||||
batchNo: string;
|
||||
result: 'WIN' | 'LOSE' | 'PUSH' | 'VOID' | 'HALF_WIN' | 'HALF_LOSE';
|
||||
tx?: TxClient;
|
||||
}) {
|
||||
return this.wallet.settleBet(
|
||||
command.userId,
|
||||
command.stake,
|
||||
command.payout,
|
||||
command.betNo,
|
||||
command.result,
|
||||
command.tx,
|
||||
`settle:${command.batchNo}:${command.betNo}`,
|
||||
);
|
||||
}
|
||||
|
||||
voidBet(command: {
|
||||
userId: bigint;
|
||||
stake: Decimal;
|
||||
betNo: string;
|
||||
businessKey: string;
|
||||
tx?: TxClient;
|
||||
}) {
|
||||
return this.wallet.settleBet(
|
||||
command.userId,
|
||||
command.stake,
|
||||
command.stake,
|
||||
command.betNo,
|
||||
'VOID',
|
||||
command.tx,
|
||||
command.businessKey,
|
||||
);
|
||||
}
|
||||
|
||||
applyResettleDelta(command: {
|
||||
userId: bigint;
|
||||
delta: Decimal;
|
||||
betNo: string;
|
||||
batchNo: string;
|
||||
tx?: TxClient;
|
||||
}) {
|
||||
return this.wallet.applyResettleDelta(
|
||||
command.userId,
|
||||
command.delta,
|
||||
command.betNo,
|
||||
command.tx,
|
||||
`resettle:${command.batchNo}:${command.betNo}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { WalletService } from './wallet.service';
|
||||
import { FundsPostingService } from './funds-posting.service';
|
||||
|
||||
@Module({
|
||||
providers: [WalletService],
|
||||
exports: [WalletService],
|
||||
providers: [WalletService, FundsPostingService],
|
||||
exports: [WalletService, FundsPostingService],
|
||||
})
|
||||
export class WalletModule {}
|
||||
|
||||
75
apps/api/src/domains/ledger/wallet.service.spec.ts
Normal file
75
apps/api/src/domains/ledger/wallet.service.spec.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { WalletService } from './wallet.service';
|
||||
import { expectAppError } from '../../testing/prisma-mock';
|
||||
|
||||
describe('WalletService', () => {
|
||||
const tx = {
|
||||
$queryRaw: jest.fn(),
|
||||
wallet: {
|
||||
update: jest.fn(),
|
||||
},
|
||||
walletTransaction: {
|
||||
findUnique: jest.fn(),
|
||||
create: jest.fn(),
|
||||
},
|
||||
};
|
||||
const prisma = {
|
||||
$transaction: jest.fn(async (fn: (client: typeof tx) => Promise<unknown>) => fn(tx)),
|
||||
};
|
||||
|
||||
let service: WalletService;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
service = new WalletService(prisma as never);
|
||||
tx.walletTransaction.findUnique.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
it('rejects settlement when frozen funds are lower than the stake', async () => {
|
||||
tx.$queryRaw.mockResolvedValue([
|
||||
{
|
||||
id: 1n,
|
||||
available_balance: new Decimal(10),
|
||||
frozen_balance: new Decimal(40),
|
||||
version: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(
|
||||
service.settleBet(
|
||||
99n,
|
||||
new Decimal(50),
|
||||
new Decimal(0),
|
||||
'BET-LOW-FROZEN',
|
||||
'LOSE',
|
||||
undefined,
|
||||
'settle:BATCH:BET-LOW-FROZEN',
|
||||
),
|
||||
).rejects.toMatchObject(expectAppError('WALLET_FROZEN_INSUFFICIENT'));
|
||||
|
||||
expect(tx.wallet.update).not.toHaveBeenCalled();
|
||||
expect(tx.walletTransaction.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lists player-approved deposits in transfer transactions', async () => {
|
||||
const listPrisma = {
|
||||
walletTransaction: {
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
count: jest.fn().mockResolvedValue(0),
|
||||
},
|
||||
};
|
||||
const listService = new WalletService(listPrisma as never);
|
||||
|
||||
await listService.listTransferTransactions({});
|
||||
|
||||
expect(listPrisma.walletTransaction.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
transactionType: expect.objectContaining({
|
||||
in: expect.arrayContaining(['PLAYER_DEPOSIT']),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,11 @@ import { generateTransactionId } from '../../shared/common/decorators';
|
||||
|
||||
type TxClient = Prisma.TransactionClient;
|
||||
|
||||
type WalletPostingOptions = {
|
||||
tx?: TxClient;
|
||||
businessKey?: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class WalletService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
@@ -31,6 +36,11 @@ export class WalletService {
|
||||
return wallets[0];
|
||||
}
|
||||
|
||||
private async findPosting(client: TxClient, businessKey?: string | null) {
|
||||
if (!businessKey) return null;
|
||||
return client.walletTransaction.findUnique({ where: { businessKey } });
|
||||
}
|
||||
|
||||
async deposit(
|
||||
userId: bigint,
|
||||
amount: Decimal | number,
|
||||
@@ -38,16 +48,24 @@ export class WalletService {
|
||||
remark?: string,
|
||||
referenceId?: string,
|
||||
transactionType = 'MANUAL_DEPOSIT',
|
||||
tx?: TxClient,
|
||||
businessKey?: string,
|
||||
) {
|
||||
const amt = new Decimal(amount);
|
||||
if (amt.lte(0)) throw appBadRequest('AMOUNT_MUST_BE_POSITIVE');
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const w = await this.lockWallet(tx, userId);
|
||||
const run = async (client: TxClient) => {
|
||||
const existing = await this.findPosting(client, businessKey);
|
||||
if (existing) return { balanceAfter: existing.balanceAfter };
|
||||
|
||||
const w = await this.lockWallet(client, userId);
|
||||
const afterLockExisting = await this.findPosting(client, businessKey);
|
||||
if (afterLockExisting) return { balanceAfter: afterLockExisting.balanceAfter };
|
||||
|
||||
const balanceBefore = new Decimal(w.available_balance);
|
||||
const balanceAfter = balanceBefore.add(amt);
|
||||
|
||||
await tx.wallet.update({
|
||||
await client.wallet.update({
|
||||
where: { id: w.id },
|
||||
data: {
|
||||
availableBalance: balanceAfter,
|
||||
@@ -55,7 +73,7 @@ export class WalletService {
|
||||
},
|
||||
});
|
||||
|
||||
await tx.walletTransaction.create({
|
||||
await client.walletTransaction.create({
|
||||
data: {
|
||||
transactionId: generateTransactionId(),
|
||||
userId,
|
||||
@@ -68,13 +86,17 @@ export class WalletService {
|
||||
frozenAfter: w.frozen_balance,
|
||||
referenceType: 'DEPOSIT',
|
||||
referenceId,
|
||||
businessKey,
|
||||
operatorId,
|
||||
remark,
|
||||
},
|
||||
});
|
||||
|
||||
return { balanceAfter };
|
||||
});
|
||||
};
|
||||
|
||||
if (tx) return run(tx);
|
||||
return this.prisma.$transaction(run);
|
||||
}
|
||||
|
||||
async withdraw(
|
||||
@@ -85,12 +107,19 @@ export class WalletService {
|
||||
referenceId?: string,
|
||||
transactionType = 'MANUAL_WITHDRAW',
|
||||
tx?: TxClient,
|
||||
businessKey?: string,
|
||||
) {
|
||||
const amt = new Decimal(amount);
|
||||
if (amt.lte(0)) throw appBadRequest('AMOUNT_MUST_BE_POSITIVE');
|
||||
|
||||
const run = async (client: TxClient) => {
|
||||
const existing = await this.findPosting(client, businessKey);
|
||||
if (existing) return { balanceAfter: existing.balanceAfter };
|
||||
|
||||
const w = await this.lockWallet(client, userId);
|
||||
const afterLockExisting = await this.findPosting(client, businessKey);
|
||||
if (afterLockExisting) return { balanceAfter: afterLockExisting.balanceAfter };
|
||||
|
||||
const balanceBefore = new Decimal(w.available_balance);
|
||||
if (balanceBefore.lt(amt)) throw appBadRequest('INSUFFICIENT_BALANCE');
|
||||
const balanceAfter = balanceBefore.sub(amt);
|
||||
@@ -116,6 +145,7 @@ export class WalletService {
|
||||
frozenAfter: w.frozen_balance,
|
||||
referenceType: 'WITHDRAW',
|
||||
referenceId,
|
||||
businessKey,
|
||||
operatorId,
|
||||
remark,
|
||||
},
|
||||
@@ -128,18 +158,30 @@ export class WalletService {
|
||||
return this.prisma.$transaction(run);
|
||||
}
|
||||
|
||||
async freezeForBet(userId: bigint, stake: Decimal | number, betId: string) {
|
||||
async freezeForBet(
|
||||
userId: bigint,
|
||||
stake: Decimal | number,
|
||||
betId: string,
|
||||
tx?: TxClient,
|
||||
businessKey = `bet:${betId}:freeze`,
|
||||
) {
|
||||
const amt = new Decimal(stake);
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const w = await this.lockWallet(tx, userId);
|
||||
const run = async (client: TxClient) => {
|
||||
const existing = await this.findPosting(client, businessKey);
|
||||
if (existing) return;
|
||||
|
||||
const w = await this.lockWallet(client, userId);
|
||||
const afterLockExisting = await this.findPosting(client, businessKey);
|
||||
if (afterLockExisting) return;
|
||||
|
||||
const avail = new Decimal(w.available_balance);
|
||||
if (avail.lt(amt)) throw appBadRequest('INSUFFICIENT_BALANCE');
|
||||
|
||||
const balanceAfter = avail.sub(amt);
|
||||
const frozenAfter = new Decimal(w.frozen_balance).add(amt);
|
||||
|
||||
await tx.wallet.update({
|
||||
await client.wallet.update({
|
||||
where: { id: w.id },
|
||||
data: {
|
||||
availableBalance: balanceAfter,
|
||||
@@ -148,7 +190,7 @@ export class WalletService {
|
||||
},
|
||||
});
|
||||
|
||||
await tx.walletTransaction.create({
|
||||
await client.walletTransaction.create({
|
||||
data: {
|
||||
transactionId: generateTransactionId(),
|
||||
userId,
|
||||
@@ -161,9 +203,13 @@ export class WalletService {
|
||||
frozenAfter,
|
||||
referenceType: 'BET',
|
||||
referenceId: betId,
|
||||
businessKey,
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
if (tx) return run(tx);
|
||||
return this.prisma.$transaction(run);
|
||||
}
|
||||
|
||||
async settleBet(
|
||||
@@ -173,8 +219,12 @@ export class WalletService {
|
||||
betId: string,
|
||||
result: 'WIN' | 'LOSE' | 'PUSH' | 'VOID' | 'HALF_WIN' | 'HALF_LOSE',
|
||||
tx?: TxClient,
|
||||
businessKey?: string,
|
||||
) {
|
||||
const run = async (client: TxClient) => {
|
||||
const existing = await this.findPosting(client, businessKey);
|
||||
if (existing) return;
|
||||
|
||||
const txTypeMap: Record<string, string> = {
|
||||
WIN: 'BET_SETTLE_WIN',
|
||||
LOSE: 'BET_SETTLE_LOSE',
|
||||
@@ -185,16 +235,22 @@ export class WalletService {
|
||||
};
|
||||
|
||||
const w = await this.lockWallet(client, userId);
|
||||
const afterLockExisting = await this.findPosting(client, businessKey);
|
||||
if (afterLockExisting) return;
|
||||
|
||||
const avail = new Decimal(w.available_balance);
|
||||
const frozen = new Decimal(w.frozen_balance);
|
||||
const frozenAfter = frozen.sub(stake);
|
||||
if (frozenAfter.lt(0)) {
|
||||
throw appBadRequest('WALLET_FROZEN_INSUFFICIENT');
|
||||
}
|
||||
const balanceAfter = avail.add(payout);
|
||||
|
||||
await client.wallet.update({
|
||||
where: { id: w.id },
|
||||
data: {
|
||||
availableBalance: balanceAfter,
|
||||
frozenBalance: frozenAfter.lt(0) ? new Decimal(0) : frozenAfter,
|
||||
frozenBalance: frozenAfter,
|
||||
version: { increment: 1 },
|
||||
},
|
||||
});
|
||||
@@ -209,9 +265,10 @@ export class WalletService {
|
||||
balanceBefore: avail,
|
||||
balanceAfter,
|
||||
frozenBefore: frozen,
|
||||
frozenAfter: frozenAfter.lt(0) ? new Decimal(0) : frozenAfter,
|
||||
frozenAfter,
|
||||
referenceType: 'BET',
|
||||
referenceId: betId,
|
||||
businessKey,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -226,11 +283,18 @@ export class WalletService {
|
||||
delta: Decimal,
|
||||
betNo: string,
|
||||
tx?: TxClient,
|
||||
businessKey?: string,
|
||||
) {
|
||||
if (delta.eq(0)) return;
|
||||
|
||||
const run = async (client: TxClient) => {
|
||||
const existing = await this.findPosting(client, businessKey);
|
||||
if (existing) return;
|
||||
|
||||
const w = await this.lockWallet(client, userId);
|
||||
const afterLockExisting = await this.findPosting(client, businessKey);
|
||||
if (afterLockExisting) return;
|
||||
|
||||
const avail = new Decimal(w.available_balance);
|
||||
const balanceAfter = avail.add(delta);
|
||||
const txType = delta.gt(0) ? 'BET_SETTLE_WIN' : 'RESETTLE_REVERSE';
|
||||
@@ -256,6 +320,7 @@ export class WalletService {
|
||||
frozenAfter: w.frozen_balance,
|
||||
referenceType: 'BET',
|
||||
referenceId: betNo,
|
||||
businessKey,
|
||||
remark: 'Resettlement adjustment',
|
||||
},
|
||||
});
|
||||
@@ -817,6 +882,7 @@ export class WalletService {
|
||||
'AGENT_DEPOSIT',
|
||||
'AGENT_WITHDRAW',
|
||||
'INITIAL_DEPOSIT',
|
||||
'PLAYER_DEPOSIT',
|
||||
];
|
||||
const where: Prisma.WalletTransactionWhereInput = {
|
||||
transactionType: params.transactionType?.trim()
|
||||
|
||||
Reference in New Issue
Block a user