Compare commits

...

2 Commits

Author SHA1 Message Date
hailin cec98e9d3e feat(contribution): 添加定时任务补发未完全解锁的贡献值
每10分钟扫描已认种但解锁状态不完整的账户,检查其直推用户认种情况,
若满足新的解锁条件则自动补发层级贡献值和奖励档位。

- 添加 findAccountsWithIncompleteUnlock 查询方法
- 添加 findPendingLevelByAccountSequence 和 claimLevelRecords 方法
- 实现 processBackfillForAccount 和 claimLevelContributions 补发逻辑
- 添加 processContributionBackfill 定时任务(每10分钟执行)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 06:25:57 -08:00
hailin 2597d0ef46 feat: 实现P2P转账功能及前端资产页面优化
- trading-service: 添加P2pTransfer模型和P2P转账API
- auth-service: 添加用户手机号查询接口用于转账验证
- frontend: 修复资产页面冻结份额显示和转账页面余额字段
- frontend: 添加P2P转账记录页面

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 06:25:42 -08:00
17 changed files with 1333 additions and 28 deletions

View File

@ -1,7 +1,9 @@
import {
Controller,
Get,
Query,
UseGuards,
BadRequestException,
} from '@nestjs/common';
import { UserService, UserProfileResult } from '@/application/services';
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
@ -23,4 +25,21 @@ export class UserController {
const result = await this.userService.getProfile(user.accountSequence);
return { success: true, data: result };
}
/**
* P2P转账验证
* GET /user/lookup?phone=13800138000
*/
@Get('lookup')
async lookupByPhone(
@Query('phone') phone: string,
@CurrentUser() currentUser: { accountSequence: string },
): Promise<{ success: boolean; data: { exists: boolean; nickname?: string; accountSequence?: string } }> {
if (!phone || phone.length !== 11) {
throw new BadRequestException('请输入有效的11位手机号');
}
const result = await this.userService.lookupByPhone(phone);
return { success: true, data: result };
}
}

View File

@ -48,6 +48,24 @@ export class UserService {
};
}
/**
* P2P转账验证
*/
async lookupByPhone(phone: string): Promise<{ exists: boolean; accountSequence?: string; nickname?: string }> {
const phoneVO = Phone.create(phone);
const user = await this.userRepository.findByPhone(phoneVO);
if (!user || user.status !== 'ACTIVE') {
return { exists: false };
}
return {
exists: true,
accountSequence: user.accountSequence.value,
nickname: user.isKycVerified ? this.maskName(user.realName!) : user.phone.masked,
};
}
/**
*
*/

View File

@ -2,6 +2,7 @@ import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { ContributionCalculationService } from '../services/contribution-calculation.service';
import { SnapshotService } from '../services/snapshot.service';
import { BonusClaimService } from '../services/bonus-claim.service';
import { ContributionRecordRepository } from '../../infrastructure/persistence/repositories/contribution-record.repository';
import { ContributionAccountRepository } from '../../infrastructure/persistence/repositories/contribution-account.repository';
import { OutboxRepository } from '../../infrastructure/persistence/repositories/outbox.repository';
@ -20,6 +21,7 @@ export class ContributionScheduler implements OnModuleInit {
constructor(
private readonly calculationService: ContributionCalculationService,
private readonly snapshotService: SnapshotService,
private readonly bonusClaimService: BonusClaimService,
private readonly contributionRecordRepository: ContributionRecordRepository,
private readonly contributionAccountRepository: ContributionAccountRepository,
private readonly outboxRepository: OutboxRepository,
@ -232,6 +234,59 @@ export class ContributionScheduler implements OnModuleInit {
}
}
/**
* 10
* /
*/
@Cron('*/10 * * * *')
async processContributionBackfill(): Promise<void> {
const lockValue = await this.redis.acquireLock(`${this.LOCK_KEY}:backfill`, 540); // 9分钟锁
if (!lockValue) {
return;
}
try {
this.logger.log('Starting contribution backfill scan...');
// 查找解锁状态不完整的账户(已认种但层级<15或奖励档位<3
const accounts = await this.contributionAccountRepository.findAccountsWithIncompleteUnlock(100);
if (accounts.length === 0) {
this.logger.debug('No accounts with incomplete unlock status found');
return;
}
this.logger.log(`Found ${accounts.length} accounts with incomplete unlock status`);
let backfilledCount = 0;
let errorCount = 0;
for (const account of accounts) {
try {
const hasBackfill = await this.bonusClaimService.processBackfillForAccount(account.accountSequence);
if (hasBackfill) {
backfilledCount++;
}
} catch (error) {
errorCount++;
this.logger.error(
`Failed to process backfill for account ${account.accountSequence}`,
error,
);
// 继续处理下一个账户
}
}
this.logger.log(
`Contribution backfill completed: ${backfilledCount} accounts backfilled, ${errorCount} errors`,
);
} catch (error) {
this.logger.error('Failed to process contribution backfill', error);
} finally {
await this.redis.releaseLock(`${this.LOCK_KEY}:backfill`, lockValue);
}
}
/**
* 4
*

View File

@ -7,10 +7,11 @@ import { OutboxRepository } from '../../infrastructure/persistence/repositories/
import { SyncedDataRepository } from '../../infrastructure/persistence/repositories/synced-data.repository';
import { UnitOfWork } from '../../infrastructure/persistence/unit-of-work/unit-of-work';
import { ContributionRecordAggregate } from '../../domain/aggregates/contribution-record.aggregate';
import { ContributionSourceType } from '../../domain/aggregates/contribution-account.aggregate';
import { ContributionAccountAggregate, ContributionSourceType } from '../../domain/aggregates/contribution-account.aggregate';
import { ContributionAmount } from '../../domain/value-objects/contribution-amount.vo';
import { DistributionRate } from '../../domain/value-objects/distribution-rate.vo';
import { ContributionRecordSyncedEvent, SystemAccountSyncedEvent } from '../../domain/events';
import { ContributionCalculatorService } from '../../domain/services/contribution-calculator.service';
import { ContributionRecordSyncedEvent, SystemAccountSyncedEvent, ContributionAccountUpdatedEvent } from '../../domain/events';
/**
*
@ -271,4 +272,352 @@ export class BonusClaimService {
aggregateType: 'ContributionAccount',
});
}
// ========== 定时任务补发逻辑 ==========
private readonly domainCalculator = new ContributionCalculatorService();
/**
*
*
* @returns
*/
async processBackfillForAccount(accountSequence: string): Promise<boolean> {
const account = await this.contributionAccountRepository.findByAccountSequence(accountSequence);
if (!account) {
return false;
}
// 重新计算直推认种用户数
const currentDirectReferralAdoptedCount = await this.syncedDataRepository.getDirectReferralAdoptedCount(
accountSequence,
);
// 计算应该解锁的层级深度和奖励档位
const expectedLevelDepth = this.domainCalculator.calculateUnlockedLevelDepth(currentDirectReferralAdoptedCount);
const expectedBonusTiers = this.domainCalculator.calculateUnlockedBonusTiers(
account.hasAdopted,
currentDirectReferralAdoptedCount,
);
let hasBackfill = false;
// 检查是否需要补发层级贡献值
if (expectedLevelDepth > account.unlockedLevelDepth) {
this.logger.log(
`[Backfill] Account ${accountSequence} level unlock: ${account.unlockedLevelDepth} -> ${expectedLevelDepth} ` +
`(directReferralAdoptedCount: ${account.directReferralAdoptedCount} -> ${currentDirectReferralAdoptedCount})`,
);
await this.unitOfWork.executeInTransaction(async () => {
// 补发层级贡献值
const levelClaimed = await this.claimLevelContributions(
accountSequence,
account.unlockedLevelDepth + 1,
expectedLevelDepth,
);
if (levelClaimed > 0) {
hasBackfill = true;
}
// 更新账户的直推认种数和解锁状态
await this.updateAccountUnlockStatus(
account,
currentDirectReferralAdoptedCount,
expectedLevelDepth,
expectedBonusTiers,
);
});
}
// 检查是否需要补发奖励档位
if (expectedBonusTiers > account.unlockedBonusTiers) {
this.logger.log(
`[Backfill] Account ${accountSequence} bonus unlock: ${account.unlockedBonusTiers} -> ${expectedBonusTiers} ` +
`(directReferralAdoptedCount: ${account.directReferralAdoptedCount} -> ${currentDirectReferralAdoptedCount})`,
);
// 使用现有的 checkAndClaimBonus 方法补发奖励
await this.checkAndClaimBonus(
accountSequence,
account.directReferralAdoptedCount,
currentDirectReferralAdoptedCount,
);
hasBackfill = true;
// 如果只有奖励档位需要补发(层级已经是最新的),也需要更新账户状态
if (expectedLevelDepth <= account.unlockedLevelDepth) {
await this.unitOfWork.executeInTransaction(async () => {
await this.updateAccountUnlockStatus(
account,
currentDirectReferralAdoptedCount,
expectedLevelDepth,
expectedBonusTiers,
);
});
}
}
return hasBackfill;
}
/**
*
* @param accountSequence
* @param minLevel
* @param maxLevel
* @returns
*/
private async claimLevelContributions(
accountSequence: string,
minLevel: number,
maxLevel: number,
): Promise<number> {
// 1. 查询待领取的层级贡献值记录
const pendingRecords = await this.unallocatedContributionRepository.findPendingLevelByAccountSequence(
accountSequence,
minLevel,
maxLevel,
);
if (pendingRecords.length === 0) {
this.logger.debug(`[Backfill] No pending level records for ${accountSequence} (levels ${minLevel}-${maxLevel})`);
return 0;
}
this.logger.log(
`[Backfill] Claiming ${pendingRecords.length} level records for ${accountSequence} (levels ${minLevel}-${maxLevel})`,
);
// 2. 查询原始认种数据,获取 treeCount 和 baseContribution
const adoptionDataMap = new Map<string, { treeCount: number; baseContribution: ContributionAmount }>();
for (const pending of pendingRecords) {
const adoptionIdStr = pending.sourceAdoptionId.toString();
if (!adoptionDataMap.has(adoptionIdStr)) {
const adoption = await this.syncedDataRepository.findSyncedAdoptionByOriginalId(pending.sourceAdoptionId);
if (adoption) {
adoptionDataMap.set(adoptionIdStr, {
treeCount: adoption.treeCount,
baseContribution: new ContributionAmount(adoption.contributionPerTree),
});
} else {
this.logger.warn(`[Backfill] Adoption not found for sourceAdoptionId: ${pending.sourceAdoptionId}`);
adoptionDataMap.set(adoptionIdStr, {
treeCount: 0,
baseContribution: new ContributionAmount(0),
});
}
}
}
// 3. 创建贡献值记录
const contributionRecords: ContributionRecordAggregate[] = [];
for (const pending of pendingRecords) {
const adoptionData = adoptionDataMap.get(pending.sourceAdoptionId.toString())!;
const record = new ContributionRecordAggregate({
accountSequence: accountSequence,
sourceType: ContributionSourceType.TEAM_LEVEL,
sourceAdoptionId: pending.sourceAdoptionId,
sourceAccountSequence: pending.sourceAccountSequence,
treeCount: adoptionData.treeCount,
baseContribution: adoptionData.baseContribution,
distributionRate: DistributionRate.LEVEL_PER,
levelDepth: pending.levelDepth!,
amount: pending.amount,
effectiveDate: pending.effectiveDate,
expireDate: pending.expireDate,
});
contributionRecords.push(record);
}
// 4. 保存贡献值记录
const savedRecords = await this.contributionRecordRepository.saveMany(contributionRecords);
// 5. 更新用户的贡献值账户(按层级分别更新)
for (const pending of pendingRecords) {
await this.contributionAccountRepository.updateContribution(
accountSequence,
ContributionSourceType.TEAM_LEVEL,
pending.amount,
pending.levelDepth,
null,
);
}
// 6. 标记待领取记录为已分配
const pendingIds = pendingRecords.map((r) => r.id);
await this.unallocatedContributionRepository.claimLevelRecords(pendingIds, accountSequence);
// 7. 计算总金额用于从 HEADQUARTERS 扣除
let totalAmount = new ContributionAmount(0);
for (const pending of pendingRecords) {
totalAmount = new ContributionAmount(totalAmount.value.plus(pending.amount.value));
}
// 8. 从 HEADQUARTERS 减少算力并删除明细记录
await this.systemAccountRepository.subtractContribution('HEADQUARTERS', null, totalAmount);
for (const pending of pendingRecords) {
await this.systemAccountRepository.deleteContributionRecordsByAdoption(
'HEADQUARTERS',
null,
pending.sourceAdoptionId,
pending.sourceAccountSequence,
);
}
// 9. 发布 HEADQUARTERS 账户更新事件
const headquartersAccount = await this.systemAccountRepository.findByTypeAndRegion('HEADQUARTERS', null);
if (headquartersAccount) {
const hqEvent = new SystemAccountSyncedEvent(
'HEADQUARTERS',
null,
headquartersAccount.name,
headquartersAccount.contributionBalance.value.toString(),
headquartersAccount.createdAt,
);
await this.outboxRepository.save({
aggregateType: SystemAccountSyncedEvent.AGGREGATE_TYPE,
aggregateId: 'HEADQUARTERS',
eventType: SystemAccountSyncedEvent.EVENT_TYPE,
payload: hqEvent.toPayload(),
});
}
// 10. 发布贡献值记录同步事件
await this.publishLevelClaimEvents(accountSequence, savedRecords, pendingRecords);
this.logger.log(
`[Backfill] Claimed level contributions for ${accountSequence}: ` +
`${pendingRecords.length} records, total amount: ${totalAmount.value.toString()}`,
);
return pendingRecords.length;
}
/**
*
*/
private async updateAccountUnlockStatus(
account: ContributionAccountAggregate,
newDirectReferralAdoptedCount: number,
expectedLevelDepth: number,
expectedBonusTiers: number,
): Promise<void> {
// 增量更新直推认种数
const previousCount = account.directReferralAdoptedCount;
if (newDirectReferralAdoptedCount > previousCount) {
for (let i = previousCount; i < newDirectReferralAdoptedCount; i++) {
account.incrementDirectReferralAdoptedCount();
}
}
await this.contributionAccountRepository.save(account);
// 发布账户更新事件
await this.publishContributionAccountUpdatedEvent(account);
}
/**
*
*/
private async publishLevelClaimEvents(
accountSequence: string,
savedRecords: ContributionRecordAggregate[],
pendingRecords: UnallocatedContribution[],
): Promise<void> {
// 1. 发布贡献值记录同步事件(用于 mining-admin-service CDC
for (const record of savedRecords) {
const event = new ContributionRecordSyncedEvent(
record.id!,
record.accountSequence,
record.sourceType,
record.sourceAdoptionId,
record.sourceAccountSequence,
record.treeCount,
record.baseContribution.value.toString(),
record.distributionRate.value.toString(),
record.levelDepth,
record.bonusTier,
record.amount.value.toString(),
record.effectiveDate,
record.expireDate,
record.isExpired,
record.createdAt,
);
await this.outboxRepository.save({
aggregateType: ContributionRecordSyncedEvent.AGGREGATE_TYPE,
aggregateId: record.id!.toString(),
eventType: ContributionRecordSyncedEvent.EVENT_TYPE,
payload: event.toPayload(),
});
}
// 2. 发布补发事件到 mining-wallet-service
const userContributions = savedRecords.map((record) => ({
accountSequence: record.accountSequence,
contributionType: 'TEAM_LEVEL',
amount: record.amount.value.toString(),
levelDepth: record.levelDepth,
effectiveDate: record.effectiveDate.toISOString(),
expireDate: record.expireDate.toISOString(),
sourceAdoptionId: record.sourceAdoptionId.toString(),
sourceAccountSequence: record.sourceAccountSequence,
isBackfill: true, // 标记为补发
}));
const eventId = `level-claim-${accountSequence}-${Date.now()}`;
const payload = {
eventType: 'LevelClaimed',
eventId,
timestamp: new Date().toISOString(),
payload: {
accountSequence,
claimedCount: savedRecords.length,
userContributions,
},
};
await this.outboxRepository.save({
eventType: 'LevelClaimed',
topic: 'contribution.level.claimed',
key: accountSequence,
payload,
aggregateId: accountSequence,
aggregateType: 'ContributionAccount',
});
}
/**
*
*/
private async publishContributionAccountUpdatedEvent(
account: ContributionAccountAggregate,
): Promise<void> {
const totalContribution = account.personalContribution.value
.plus(account.totalLevelPending.value)
.plus(account.totalBonusPending.value);
const event = new ContributionAccountUpdatedEvent(
account.accountSequence,
account.personalContribution.value.toString(),
account.totalLevelPending.value.toString(),
account.totalBonusPending.value.toString(),
totalContribution.toString(),
account.effectiveContribution.value.toString(),
account.hasAdopted,
account.directReferralAdoptedCount,
account.unlockedLevelDepth,
account.unlockedBonusTiers,
account.createdAt,
);
await this.outboxRepository.save({
aggregateType: ContributionAccountUpdatedEvent.AGGREGATE_TYPE,
aggregateId: account.accountSequence,
eventType: ContributionAccountUpdatedEvent.EVENT_TYPE,
payload: event.toPayload(),
});
}
}

View File

@ -233,6 +233,31 @@ export class ContributionAccountRepository implements IContributionAccountReposi
return records.map((r) => this.toDomain(r));
}
/**
*
*
* @param limit
* @returns
*/
async findAccountsWithIncompleteUnlock(limit: number = 100): Promise<ContributionAccountAggregate[]> {
// 查找已认种但未达到满解锁状态的账户:
// - unlockedLevelDepth < 15 或
// - unlockedBonusTiers < 3
const records = await this.client.contributionAccount.findMany({
where: {
hasAdopted: true,
OR: [
{ unlockedLevelDepth: { lt: 15 } },
{ unlockedBonusTiers: { lt: 3 } },
],
},
orderBy: { updatedAt: 'asc' }, // 优先处理最久未更新的
take: limit,
});
return records.map((r) => this.toDomain(r));
}
/**
*
*/

View File

@ -192,6 +192,54 @@ export class UnallocatedContributionRepository {
return records.map((r) => this.toDomain(r));
}
/**
*
* @param accountSequence
* @param minLevel
* @param maxLevel
*/
async findPendingLevelByAccountSequence(
accountSequence: string,
minLevel: number,
maxLevel: number,
): Promise<UnallocatedContribution[]> {
const records = await this.client.unallocatedContribution.findMany({
where: {
wouldBeAccountSequence: accountSequence,
unallocType: 'LEVEL_OVERFLOW',
levelDepth: {
gte: minLevel,
lte: maxLevel,
},
status: 'PENDING',
},
orderBy: { levelDepth: 'asc' },
});
return records.map((r) => this.toDomain(r));
}
/**
* -
* @param ids ID列表
* @param accountSequence
*/
async claimLevelRecords(ids: bigint[], accountSequence: string): Promise<void> {
if (ids.length === 0) return;
await this.client.unallocatedContribution.updateMany({
where: {
id: { in: ids },
status: 'PENDING',
},
data: {
status: 'ALLOCATED_TO_USER',
allocatedAt: new Date(),
allocatedToAccountSequence: accountSequence,
},
});
}
/**
*
*/

View File

@ -0,0 +1,34 @@
-- CreateTable
CREATE TABLE "p2p_transfers" (
"id" TEXT NOT NULL,
"transfer_no" TEXT NOT NULL,
"from_account_sequence" TEXT NOT NULL,
"to_account_sequence" TEXT NOT NULL,
"to_phone" TEXT NOT NULL,
"to_nickname" TEXT,
"from_phone" TEXT,
"from_nickname" TEXT,
"amount" DECIMAL(30,8) NOT NULL,
"memo" TEXT,
"status" TEXT NOT NULL DEFAULT 'PENDING',
"error_message" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"completed_at" TIMESTAMP(3),
CONSTRAINT "p2p_transfers_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "p2p_transfers_transfer_no_key" ON "p2p_transfers"("transfer_no");
-- CreateIndex
CREATE INDEX "p2p_transfers_from_account_sequence_idx" ON "p2p_transfers"("from_account_sequence");
-- CreateIndex
CREATE INDEX "p2p_transfers_to_account_sequence_idx" ON "p2p_transfers"("to_account_sequence");
-- CreateIndex
CREATE INDEX "p2p_transfers_status_idx" ON "p2p_transfers"("status");
-- CreateIndex
CREATE INDEX "p2p_transfers_created_at_idx" ON "p2p_transfers"("created_at" DESC);

View File

@ -392,6 +392,30 @@ model TransferRecord {
@@map("transfer_records")
}
// P2P用户间转账记录
model P2pTransfer {
id String @id @default(uuid())
transferNo String @unique @map("transfer_no")
fromAccountSequence String @map("from_account_sequence")
toAccountSequence String @map("to_account_sequence")
toPhone String @map("to_phone") // 收款方手机号(用于显示)
toNickname String? @map("to_nickname") // 收款方昵称
fromPhone String? @map("from_phone") // 发送方手机号
fromNickname String? @map("from_nickname") // 发送方昵称
amount Decimal @db.Decimal(30, 8)
memo String? @db.Text // 备注
status String @default("PENDING") // PENDING, COMPLETED, FAILED
errorMessage String? @map("error_message")
createdAt DateTime @default(now()) @map("created_at")
completedAt DateTime? @map("completed_at")
@@index([fromAccountSequence])
@@index([toAccountSequence])
@@index([status])
@@index([createdAt(sort: Desc)])
@@map("p2p_transfers")
}
// ==================== Outbox ====================
enum OutboxStatus {

View File

@ -3,6 +3,7 @@ import { ApplicationModule } from '../application/application.module';
import { InfrastructureModule } from '../infrastructure/infrastructure.module';
import { TradingController } from './controllers/trading.controller';
import { TransferController } from './controllers/transfer.controller';
import { P2pTransferController } from './controllers/p2p-transfer.controller';
import { HealthController } from './controllers/health.controller';
import { AdminController } from './controllers/admin.controller';
import { PriceController } from './controllers/price.controller';
@ -17,6 +18,7 @@ import { PriceGateway } from './gateways/price.gateway';
controllers: [
TradingController,
TransferController,
P2pTransferController,
HealthController,
AdminController,
PriceController,

View File

@ -0,0 +1,76 @@
import { Controller, Get, Post, Body, Query, Param, Req, Headers, BadRequestException } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery, ApiParam } from '@nestjs/swagger';
import { IsString, IsOptional, Length, Matches } from 'class-validator';
import { P2pTransferService } from '../../application/services/p2p-transfer.service';
class P2pTransferDto {
@IsString()
@Length(11, 11)
@Matches(/^\d{11}$/, { message: '请输入有效的11位手机号' })
toPhone: string;
@IsString()
amount: string;
@IsOptional()
@IsString()
@Length(0, 100)
memo?: string;
}
@ApiTags('P2P Transfer')
@ApiBearerAuth()
@Controller('p2p')
export class P2pTransferController {
constructor(private readonly p2pTransferService: P2pTransferService) {}
@Post('transfer')
@ApiOperation({ summary: 'P2P转账积分值' })
async transfer(
@Body() dto: P2pTransferDto,
@Headers('authorization') authHeader: string,
@Req() req: any,
) {
const accountSequence = req.user?.accountSequence;
if (!accountSequence) {
throw new BadRequestException('Unauthorized');
}
const token = authHeader?.replace('Bearer ', '') || '';
const result = await this.p2pTransferService.transfer(
accountSequence,
dto.toPhone,
dto.amount,
dto.memo,
token,
);
return { success: true, data: result };
}
@Get('transfers/:accountSequence')
@ApiOperation({ summary: '获取P2P转账历史' })
@ApiParam({ name: 'accountSequence', required: true, description: '账户序列号' })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'pageSize', required: false, type: Number })
async getHistory(
@Param('accountSequence') accountSequence: string,
@Req() req: any,
@Query('page') page?: number,
@Query('pageSize') pageSize?: number,
) {
// 验证只能查询自己的转账历史
const currentUser = req.user?.accountSequence;
if (!currentUser || currentUser !== accountSequence) {
throw new BadRequestException('Unauthorized');
}
const result = await this.p2pTransferService.getTransferHistory(
accountSequence,
page ?? 1,
pageSize ?? 20,
);
return { success: true, data: result.data };
}
}

View File

@ -4,6 +4,7 @@ import { InfrastructureModule } from '../infrastructure/infrastructure.module';
import { ApiModule } from '../api/api.module';
import { OrderService } from './services/order.service';
import { TransferService } from './services/transfer.service';
import { P2pTransferService } from './services/p2p-transfer.service';
import { PriceService } from './services/price.service';
import { BurnService } from './services/burn.service';
import { AssetService } from './services/asset.service';
@ -27,6 +28,7 @@ import { C2cExpiryScheduler } from './schedulers/c2c-expiry.scheduler';
AssetService,
OrderService,
TransferService,
P2pTransferService,
MarketMakerService,
C2cService,
// Schedulers
@ -35,6 +37,6 @@ import { C2cExpiryScheduler } from './schedulers/c2c-expiry.scheduler';
PriceBroadcastScheduler,
C2cExpiryScheduler,
],
exports: [OrderService, TransferService, PriceService, BurnService, AssetService, MarketMakerService, C2cService],
exports: [OrderService, TransferService, P2pTransferService, PriceService, BurnService, AssetService, MarketMakerService, C2cService],
})
export class ApplicationModule {}

View File

@ -0,0 +1,297 @@
import { Injectable, Logger, BadRequestException, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { TradingAccountRepository } from '../../infrastructure/persistence/repositories/trading-account.repository';
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
import { TradingAccountAggregate } from '../../domain/aggregates/trading-account.aggregate';
import { Money } from '../../domain/value-objects/money.vo';
interface RecipientInfo {
exists: boolean;
accountSequence?: string;
nickname?: string;
}
export interface P2pTransferResult {
transferNo: string;
amount: string;
toPhone: string;
toNickname?: string;
status: string;
createdAt: Date;
}
export interface P2pTransferHistoryItem {
transferNo: string;
fromAccountSequence: string;
toAccountSequence: string;
toPhone: string;
amount: string;
memo?: string | null;
status: string;
createdAt: Date;
}
@Injectable()
export class P2pTransferService {
private readonly logger = new Logger(P2pTransferService.name);
private readonly authServiceUrl: string;
private readonly minTransferAmount: number;
constructor(
private readonly accountRepository: TradingAccountRepository,
private readonly prisma: PrismaService,
private readonly configService: ConfigService,
) {
this.authServiceUrl = this.configService.get<string>('AUTH_SERVICE_URL', 'http://localhost:3020');
this.minTransferAmount = this.configService.get<number>('MIN_P2P_TRANSFER_AMOUNT', 0.01);
}
/**
*
*/
async lookupRecipient(phone: string, token: string): Promise<RecipientInfo> {
try {
const response = await fetch(`${this.authServiceUrl}/api/v2/auth/user/lookup?phone=${phone}`, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
return { exists: false };
}
const result = await response.json();
if (!result.success || !result.data) {
return { exists: false };
}
return result.data;
} catch (error: any) {
this.logger.warn(`Failed to lookup recipient: ${error.message}`);
return { exists: false };
}
}
/**
* P2P转账
*/
async transfer(
fromAccountSequence: string,
toPhone: string,
amount: string,
memo?: string,
token?: string,
): Promise<P2pTransferResult> {
const transferAmount = new Money(amount);
// 验证转账金额
if (transferAmount.value.lessThan(this.minTransferAmount)) {
throw new BadRequestException(`最小转账金额为 ${this.minTransferAmount}`);
}
// 查找收款方
const recipient = await this.lookupRecipient(toPhone, token || '');
if (!recipient.exists || !recipient.accountSequence) {
throw new NotFoundException('收款方账户不存在');
}
// 不能转给自己
if (recipient.accountSequence === fromAccountSequence) {
throw new BadRequestException('不能转账给自己');
}
// 查找发送方账户
const fromAccount = await this.accountRepository.findByAccountSequence(fromAccountSequence);
if (!fromAccount) {
throw new NotFoundException('发送方账户不存在');
}
// 检查余额
if (fromAccount.availableCash.isLessThan(transferAmount)) {
throw new BadRequestException('可用积分值不足');
}
const transferNo = this.generateTransferNo();
// 使用事务执行转账
try {
await this.prisma.$transaction(async (tx) => {
// 1. 创建转账记录
await tx.p2pTransfer.create({
data: {
transferNo,
fromAccountSequence,
toAccountSequence: recipient.accountSequence!,
toPhone,
toNickname: recipient.nickname,
amount: transferAmount.value,
memo,
status: 'PENDING',
},
});
// 2. 扣减发送方余额
fromAccount.withdraw(transferAmount, transferNo);
// 保存发送方账户变动
await tx.tradingAccount.update({
where: { accountSequence: fromAccountSequence },
data: {
cashBalance: fromAccount.cashBalance.value,
},
});
// 记录发送方交易流水
for (const txn of fromAccount.pendingTransactions) {
await tx.tradingTransaction.create({
data: {
accountSequence: fromAccountSequence,
type: txn.type,
assetType: txn.assetType,
amount: txn.amount.value,
balanceBefore: txn.balanceBefore.value,
balanceAfter: txn.balanceAfter.value,
referenceId: transferNo,
referenceType: 'P2P_TRANSFER',
counterpartyType: 'USER',
counterpartyAccountSeq: recipient.accountSequence,
memo: `P2P转出给 ${toPhone}`,
},
});
}
fromAccount.clearPendingTransactions();
// 3. 增加收款方余额
let toAccount = await this.accountRepository.findByAccountSequence(recipient.accountSequence!);
if (!toAccount) {
toAccount = TradingAccountAggregate.create(recipient.accountSequence!);
// 创建新账户
await tx.tradingAccount.create({
data: {
accountSequence: recipient.accountSequence!,
shareBalance: 0,
cashBalance: 0,
frozenShares: 0,
frozenCash: 0,
totalBought: 0,
totalSold: 0,
},
});
}
toAccount.deposit(transferAmount, transferNo);
// 保存收款方账户变动
await tx.tradingAccount.update({
where: { accountSequence: recipient.accountSequence! },
data: {
cashBalance: toAccount.cashBalance.value,
},
});
// 记录收款方交易流水
for (const txn of toAccount.pendingTransactions) {
await tx.tradingTransaction.create({
data: {
accountSequence: recipient.accountSequence!,
type: txn.type,
assetType: txn.assetType,
amount: txn.amount.value,
balanceBefore: txn.balanceBefore.value,
balanceAfter: txn.balanceAfter.value,
referenceId: transferNo,
referenceType: 'P2P_TRANSFER',
counterpartyType: 'USER',
counterpartyAccountSeq: fromAccountSequence,
memo: `P2P转入来自 ${fromAccountSequence}`,
},
});
}
// 4. 更新转账记录为完成
await tx.p2pTransfer.update({
where: { transferNo },
data: {
status: 'COMPLETED',
completedAt: new Date(),
},
});
});
this.logger.log(`P2P transfer completed: ${transferNo}, ${fromAccountSequence} -> ${toPhone}, amount=${amount}`);
return {
transferNo,
amount,
toPhone,
toNickname: recipient.nickname,
status: 'COMPLETED',
createdAt: new Date(),
};
} catch (error: any) {
// 更新转账记录为失败
await this.prisma.p2pTransfer.update({
where: { transferNo },
data: {
status: 'FAILED',
errorMessage: error.message,
},
}).catch(() => {});
this.logger.error(`P2P transfer failed: ${transferNo}, ${error.message}`);
throw error;
}
}
/**
* P2P转账历史
*/
async getTransferHistory(
accountSequence: string,
page: number = 1,
pageSize: number = 20,
): Promise<{ data: P2pTransferHistoryItem[]; total: number }> {
const [records, total] = await Promise.all([
this.prisma.p2pTransfer.findMany({
where: {
OR: [
{ fromAccountSequence: accountSequence },
{ toAccountSequence: accountSequence },
],
},
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.p2pTransfer.count({
where: {
OR: [
{ fromAccountSequence: accountSequence },
{ toAccountSequence: accountSequence },
],
},
}),
]);
const data = records.map((record) => ({
transferNo: record.transferNo,
fromAccountSequence: record.fromAccountSequence,
toAccountSequence: record.toAccountSequence,
toPhone: record.toPhone,
amount: record.amount.toString(),
memo: record.memo,
status: record.status,
createdAt: record.createdAt,
} as P2pTransferHistoryItem));
return { data, total };
}
private generateTransferNo(): string {
const timestamp = Date.now().toString(36);
const random = Math.random().toString(36).substring(2, 8);
return `P2P${timestamp}${random}`.toUpperCase();
}
}

View File

@ -23,6 +23,7 @@ import '../../presentation/pages/c2c/c2c_order_detail_page.dart';
import '../../presentation/pages/profile/team_page.dart';
import '../../presentation/pages/profile/trading_records_page.dart';
import '../../presentation/pages/trading/transfer_records_page.dart';
import '../../presentation/pages/asset/p2p_transfer_records_page.dart';
import '../../presentation/pages/profile/help_center_page.dart';
import '../../presentation/pages/profile/about_page.dart';
import '../../presentation/widgets/main_shell.dart';
@ -167,6 +168,10 @@ final appRouterProvider = Provider<GoRouter>((ref) {
path: Routes.transferRecords,
builder: (context, state) => const TransferRecordsPage(),
),
GoRoute(
path: Routes.p2pTransferRecords,
builder: (context, state) => const P2pTransferRecordsPage(),
),
GoRoute(
path: Routes.helpCenter,
builder: (context, state) => const HelpCenterPage(),

View File

@ -26,6 +26,8 @@ class Routes {
static const String tradingRecords = '/trading-records';
//
static const String transferRecords = '/transfer-records';
// P2P转账记录
static const String p2pTransferRecords = '/p2p-transfer-records';
//
static const String helpCenter = '/help-center';
static const String about = '/about';

View File

@ -8,9 +8,12 @@ import '../../../core/network/price_websocket_service.dart';
import '../../../core/constants/app_constants.dart';
import '../../../core/constants/app_colors.dart';
import '../../../domain/entities/asset_display.dart';
import '../../../domain/entities/trade_order.dart';
import '../../../data/models/trade_order_model.dart';
import '../../providers/user_providers.dart';
import '../../providers/asset_providers.dart';
import '../../providers/mining_providers.dart';
import '../../providers/trading_providers.dart';
import '../../widgets/shimmer_loading.dart';
class AssetPage extends ConsumerStatefulWidget {
@ -160,11 +163,14 @@ class _AssetPageState extends ConsumerState<AssetPage> {
final assetAsync = ref.watch(accountAssetProvider(accountSequence));
// mining-service
final shareAccountAsync = ref.watch(shareAccountProvider(accountSequence));
//
final ordersAsync = ref.watch(ordersProvider);
//
final isLoading = assetAsync.isLoading || accountSequence.isEmpty;
final asset = assetAsync.valueOrNull;
final shareAccount = shareAccountAsync.valueOrNull;
final orders = ordersAsync.valueOrNull?.data ?? [];
// 使 mining-service
final perSecondEarning = shareAccount?.perSecondEarning ?? '0';
@ -205,6 +211,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
_lastAsset = null;
ref.invalidate(accountAssetProvider(accountSequence));
ref.invalidate(shareAccountProvider(accountSequence));
ref.invalidate(ordersProvider);
},
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
@ -227,7 +234,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
_buildQuickActions(context),
const SizedBox(height: 24),
// -
_buildAssetList(context, asset, isLoading, _currentShareBalance, perSecondEarning),
_buildAssetList(context, asset, isLoading, _currentShareBalance, perSecondEarning, orders),
const SizedBox(height: 24),
//
_buildEarningsCard(context, asset, isLoading),
@ -480,7 +487,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
);
}
Widget _buildAssetList(BuildContext context, AssetDisplay? asset, bool isLoading, double currentShareBalance, String perSecondEarning) {
Widget _buildAssetList(BuildContext context, AssetDisplay? asset, bool isLoading, double currentShareBalance, String perSecondEarning, List<TradeOrder> orders) {
// 使
final shareBalance = asset != null && currentShareBalance > 0
? currentShareBalance
@ -490,6 +497,10 @@ class _AssetPageState extends ConsumerState<AssetPage> {
final currentPrice = double.tryParse(asset?.currentPrice ?? '0') ?? 0;
final isDark = AppColors.isDark(context);
//
final frozenShares = double.tryParse(asset?.frozenShares ?? '0') ?? 0;
final frozenSharesSubtitle = _getFrozenSharesSubtitle(frozenShares, orders);
return Column(
children: [
// -
@ -528,12 +539,32 @@ class _AssetPageState extends ConsumerState<AssetPage> {
title: '冻结积分股',
amount: asset?.frozenShares,
isLoading: isLoading,
subtitle: '交易挂单中',
subtitle: frozenSharesSubtitle,
onTap: frozenShares > 0 ? () => context.push(Routes.tradingRecords) : null,
),
],
);
}
///
String? _getFrozenSharesSubtitle(double frozenShares, List<TradeOrder> orders) {
if (frozenShares <= 0) {
return null;
}
// pending partial
final hasPendingSellOrder = orders.any(
(order) => order.isSell && (order.isPending || order.isPartial),
);
if (hasPendingSellOrder) {
return '交易挂单中';
}
//
return '处理中';
}
Widget _buildAssetItem({
required BuildContext context,
required IconData icon,
@ -542,6 +573,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
required String title,
String? amount,
bool isLoading = false,
VoidCallback? onTap,
String? valueInCny,
String? tag,
String? growthText,
@ -551,7 +583,10 @@ class _AssetPageState extends ConsumerState<AssetPage> {
String? subtitle,
}) {
final isDark = AppColors.isDark(context);
return Container(
return GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.cardOf(context),
@ -698,6 +733,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
Icon(Icons.chevron_right, size: 14, color: AppColors.textMutedOf(context)),
],
),
),
);
}

View File

@ -0,0 +1,300 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import '../../../core/constants/app_colors.dart';
import '../../../core/utils/format_utils.dart';
import '../../../data/models/p2p_transfer_model.dart';
import '../../providers/transfer_providers.dart';
import '../../providers/user_providers.dart';
/// P2P转账记录页面
class P2pTransferRecordsPage extends ConsumerWidget {
const P2pTransferRecordsPage({super.key});
static const Color _orange = Color(0xFFFF6B00);
static const Color _green = Color(0xFF10B981);
static const Color _red = Color(0xFFEF4444);
static const Color _grayText = Color(0xFF6B7280);
static const Color _darkText = Color(0xFF1F2937);
static const Color _bgGray = Color(0xFFF3F4F6);
@override
Widget build(BuildContext context, WidgetRef ref) {
final user = ref.watch(userNotifierProvider);
final accountSequence = user.accountSequence ?? '';
final recordsAsync = ref.watch(p2pTransferHistoryProvider(accountSequence));
return Scaffold(
backgroundColor: _bgGray,
appBar: AppBar(
title: const Text(
'转账记录',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: _darkText,
),
),
centerTitle: true,
backgroundColor: Colors.white,
elevation: 0,
iconTheme: const IconThemeData(color: _darkText),
),
body: RefreshIndicator(
onRefresh: () async {
ref.invalidate(p2pTransferHistoryProvider(accountSequence));
},
child: recordsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 48, color: _grayText),
const SizedBox(height: 16),
const Text(
'加载失败',
style: TextStyle(color: _grayText),
),
const SizedBox(height: 8),
TextButton(
onPressed: () => ref.invalidate(p2pTransferHistoryProvider(accountSequence)),
child: const Text('点击重试'),
),
],
),
),
data: (records) {
if (records.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.receipt_long,
size: 64,
color: _grayText.withOpacity(0.5),
),
const SizedBox(height: 16),
const Text(
'暂无转账记录',
style: TextStyle(
fontSize: 16,
color: _grayText,
),
),
],
),
);
}
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: records.length,
itemBuilder: (context, index) {
return _buildRecordCard(context, records[index], accountSequence);
},
);
},
),
),
);
}
Widget _buildRecordCard(BuildContext context, P2pTransferModel record, String myAccountSequence) {
//
final isSend = record.fromAccountSequence == myAccountSequence;
final statusColor = _getStatusColor(record.status);
final statusText = _getStatusText(record.status);
final dateFormat = DateFormat('yyyy-MM-dd HH:mm:ss');
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
//
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: (isSend ? _orange : _green).withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
isSend ? Icons.arrow_upward : Icons.arrow_downward,
size: 18,
color: isSend ? _orange : _green,
),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
isSend ? '转出' : '转入',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: _darkText,
),
),
const SizedBox(height: 2),
Text(
dateFormat.format(record.createdAt),
style: const TextStyle(
fontSize: 12,
color: _grayText,
),
),
],
),
],
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: statusColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(4),
),
child: Text(
statusText,
style: TextStyle(
fontSize: 12,
color: statusColor,
fontWeight: FontWeight.w500,
),
),
),
],
),
const SizedBox(height: 12),
const Divider(height: 1),
const SizedBox(height: 12),
//
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'转账金额',
style: TextStyle(fontSize: 13, color: _grayText),
),
Text(
'${isSend ? '-' : '+'}${formatAmount(record.amount)} 积分值',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: isSend ? _orange : _green,
),
),
],
),
const SizedBox(height: 8),
//
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
isSend ? '收款方' : '付款方',
style: const TextStyle(fontSize: 12, color: _grayText),
),
Text(
_maskPhone(record.toPhone),
style: const TextStyle(
fontSize: 12,
color: _grayText,
),
),
],
),
//
if (record.memo != null && record.memo!.isNotEmpty) ...[
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'备注',
style: TextStyle(fontSize: 12, color: _grayText),
),
Flexible(
child: Text(
record.memo!,
style: const TextStyle(
fontSize: 12,
color: _darkText,
),
textAlign: TextAlign.right,
overflow: TextOverflow.ellipsis,
),
),
],
),
],
const SizedBox(height: 8),
//
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'转账单号',
style: TextStyle(fontSize: 12, color: _grayText),
),
Text(
record.transferNo,
style: const TextStyle(
fontSize: 12,
color: _grayText,
fontFamily: 'monospace',
),
),
],
),
],
),
);
}
String _maskPhone(String phone) {
if (phone.length != 11) return phone;
return '${phone.substring(0, 3)}****${phone.substring(7)}';
}
Color _getStatusColor(String status) {
switch (status.toUpperCase()) {
case 'COMPLETED':
case 'SUCCESS':
return _green;
case 'PENDING':
return _orange;
case 'FAILED':
return _red;
default:
return _grayText;
}
}
String _getStatusText(String status) {
switch (status.toUpperCase()) {
case 'COMPLETED':
case 'SUCCESS':
return '已完成';
case 'PENDING':
return '处理中';
case 'FAILED':
return '失败';
default:
return status;
}
}
}

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/router/routes.dart';
import '../../../core/utils/format_utils.dart';
import '../../providers/user_providers.dart';
import '../../providers/asset_providers.dart';
@ -44,7 +45,7 @@ class _SendSharesPageState extends ConsumerState<SendSharesPage> {
final assetAsync = ref.watch(accountAssetProvider(accountSequence));
final transferState = ref.watch(transferNotifierProvider);
final availableShares = assetAsync.valueOrNull?.availableShares ?? '0';
final availableCash = assetAsync.valueOrNull?.availableCash ?? '0';
return Scaffold(
backgroundColor: _bgGray,
@ -64,6 +65,18 @@ class _SendSharesPageState extends ConsumerState<SendSharesPage> {
),
),
centerTitle: true,
actions: [
TextButton(
onPressed: () => context.push(Routes.p2pTransferRecords),
child: const Text(
'转账记录',
style: TextStyle(
fontSize: 14,
color: _orange,
),
),
),
],
),
body: SingleChildScrollView(
child: Column(
@ -76,7 +89,7 @@ class _SendSharesPageState extends ConsumerState<SendSharesPage> {
const SizedBox(height: 16),
//
_buildAmountSection(availableShares),
_buildAmountSection(availableCash),
const SizedBox(height: 16),
@ -86,7 +99,7 @@ class _SendSharesPageState extends ConsumerState<SendSharesPage> {
const SizedBox(height: 32),
//
_buildSendButton(transferState, availableShares),
_buildSendButton(transferState, availableCash),
const SizedBox(height: 16),
@ -248,7 +261,7 @@ class _SendSharesPageState extends ConsumerState<SendSharesPage> {
);
}
Widget _buildAmountSection(String availableShares) {
Widget _buildAmountSection(String availableCash) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.all(20),
@ -271,7 +284,7 @@ class _SendSharesPageState extends ConsumerState<SendSharesPage> {
),
),
Text(
'可用: ${formatAmount(availableShares)}',
'可用: ${formatAmount(availableCash)}',
style: const TextStyle(
fontSize: 12,
color: _grayText,
@ -301,7 +314,7 @@ class _SendSharesPageState extends ConsumerState<SendSharesPage> {
),
suffixIcon: TextButton(
onPressed: () {
_amountController.text = availableShares;
_amountController.text = availableCash;
},
child: const Text(
'全部',
@ -365,9 +378,9 @@ class _SendSharesPageState extends ConsumerState<SendSharesPage> {
);
}
Widget _buildSendButton(TransferState transferState, String availableShares) {
Widget _buildSendButton(TransferState transferState, String availableCash) {
final amount = double.tryParse(_amountController.text) ?? 0;
final available = double.tryParse(availableShares) ?? 0;
final available = double.tryParse(availableCash) ?? 0;
final isValid = _isRecipientVerified && amount > 0 && amount <= available;
return Padding(