This commit is contained in:
wchino
2026-06-13 17:38:25 +08:00
parent e7e938f261
commit 7b33d9f9fa
190 changed files with 23222 additions and 4336 deletions

View 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',
);
});
});

View 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}`,
);
}
}

View File

@@ -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 {}

View 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']),
}),
}),
}),
);
});
});

View File

@@ -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()