P2: ride types + class-aware matching + round-trip + dispatch + wallet + FCM + WS live location/WebRTC
- ride-types: per-tenant catalog (economy/comfort/electric/family_van/women/scooter), seeded
- matching now class-scoped (geo:drivers:{tenant}:{class}); trips match by requested type
- trips: is_round_trip (doubles distance for fare) + payment_method + wallet settlement on paid
- dispatch: operator creates trip for phone customer (role-guarded)
- wallet: balance + credit/debit + txns + self topup; wallet-paid trips settle rider→driver
- notifications: FCM device tokens + best-effort push (assign event)
- realtime: trip:join, live driver:location broadcast, WebRTC call signaling (offer/answer/ice/end)
- migration InitP2 (ride_types, wallets, wallet_txns, device_tokens, trips.is_round_trip)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
/** حركة محفظة. الجدول: tripz_wallet_txns. */
|
||||
@Entity('wallet_txns')
|
||||
@Index(['tenant_id', 'wallet_id'])
|
||||
export class WalletTxn {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
tenant_id: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
wallet_id: string;
|
||||
|
||||
@Column({ type: 'numeric', precision: 12, scale: 3 })
|
||||
amount: number;
|
||||
|
||||
@Column()
|
||||
type: string; // credit | debit
|
||||
|
||||
@Column()
|
||||
reason: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
ref: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
created_at: Date;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
/** محفظة المستخدم (راكب/سائق). الجدول: tripz_wallets. */
|
||||
@Entity('wallets')
|
||||
@Index(['tenant_id', 'user_id'], { unique: true })
|
||||
export class Wallet {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
tenant_id: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
user_id: string;
|
||||
|
||||
@Column({ type: 'numeric', precision: 12, scale: 3, default: 0 })
|
||||
balance: number;
|
||||
|
||||
@Column({ default: 'JOD' })
|
||||
currency: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
created_at: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updated_at: Date;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { WalletService } from './wallet.service';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator';
|
||||
|
||||
@ApiTags('wallet')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('wallet')
|
||||
export class WalletController {
|
||||
constructor(private readonly wallet: WalletService) {}
|
||||
|
||||
@Get()
|
||||
async me(@CurrentUser() user: AuthUser) {
|
||||
return this.wallet.getOrCreate(user.tenantId, user.userId);
|
||||
}
|
||||
|
||||
@Get('transactions')
|
||||
async history(@CurrentUser() user: AuthUser) {
|
||||
const w = await this.wallet.getOrCreate(user.tenantId, user.userId);
|
||||
return this.wallet.history(user.tenantId, w.id);
|
||||
}
|
||||
|
||||
// شحن ذاتي تجريبي (كاش-إن). للإنتاج يُربط بمزوّد دفع أو يُقيَّد للأدمن.
|
||||
@Post('topup')
|
||||
topup(@CurrentUser() user: AuthUser, @Body('amount') amount: number) {
|
||||
return this.wallet.credit(user.tenantId, user.userId, Number(amount), 'topup');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Wallet } from './entities/wallet.entity';
|
||||
import { WalletTxn } from './entities/wallet-txn.entity';
|
||||
import { WalletService } from './wallet.service';
|
||||
import { WalletController } from './wallet.controller';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Wallet, WalletTxn])],
|
||||
controllers: [WalletController],
|
||||
providers: [WalletService],
|
||||
exports: [WalletService],
|
||||
})
|
||||
export class WalletModule {}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Wallet } from './entities/wallet.entity';
|
||||
import { WalletTxn } from './entities/wallet-txn.entity';
|
||||
|
||||
@Injectable()
|
||||
export class WalletService {
|
||||
constructor(
|
||||
@InjectRepository(Wallet) private readonly wallets: Repository<Wallet>,
|
||||
@InjectRepository(WalletTxn) private readonly txns: Repository<WalletTxn>,
|
||||
) {}
|
||||
|
||||
async getOrCreate(tenantId: string, userId: string, currency = 'JOD'): Promise<Wallet> {
|
||||
let w = await this.wallets.findOne({ where: { tenant_id: tenantId, user_id: userId } });
|
||||
if (!w) {
|
||||
w = await this.wallets.save(
|
||||
this.wallets.create({ tenant_id: tenantId, user_id: userId, balance: 0, currency }),
|
||||
);
|
||||
}
|
||||
return w;
|
||||
}
|
||||
|
||||
private num(v: number | string): number {
|
||||
return typeof v === 'string' ? Number(v) : v;
|
||||
}
|
||||
|
||||
async credit(tenantId: string, userId: string, amount: number, reason: string, ref?: string) {
|
||||
if (amount <= 0) throw new BadRequestException('amount must be > 0');
|
||||
const w = await this.getOrCreate(tenantId, userId);
|
||||
w.balance = Number((this.num(w.balance) + amount).toFixed(3));
|
||||
await this.wallets.save(w);
|
||||
await this.txns.save(
|
||||
this.txns.create({ tenant_id: tenantId, wallet_id: w.id, amount, type: 'credit', reason, ref }),
|
||||
);
|
||||
return w;
|
||||
}
|
||||
|
||||
async debit(tenantId: string, userId: string, amount: number, reason: string, ref?: string) {
|
||||
if (amount <= 0) throw new BadRequestException('amount must be > 0');
|
||||
const w = await this.getOrCreate(tenantId, userId);
|
||||
if (this.num(w.balance) < amount) throw new BadRequestException('Insufficient balance');
|
||||
w.balance = Number((this.num(w.balance) - amount).toFixed(3));
|
||||
await this.wallets.save(w);
|
||||
await this.txns.save(
|
||||
this.txns.create({ tenant_id: tenantId, wallet_id: w.id, amount, type: 'debit', reason, ref }),
|
||||
);
|
||||
return w;
|
||||
}
|
||||
|
||||
history(tenantId: string, walletId: string) {
|
||||
return this.txns.find({
|
||||
where: { tenant_id: tenantId, wallet_id: walletId },
|
||||
order: { created_at: 'DESC' },
|
||||
take: 100,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user