P3: payments + payouts (multi-country adapter pattern, wallet-integrated)
- payments: cash instant + gateway intent/redirect + webhook confirm; topup credits wallet - payouts: driver request (holds wallet.debit) + admin complete/fail (fail refunds) - tables tripz_pay_payments / tripz_pay_payouts (logical payment schema, extractable later) - providers: cash/cliq/zaincash/syriatel/mtn/shamcash/paymob/fawry (structure ready, creds later) - migration InitPayments; wired into app.module Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,7 @@ import { RideTypesModule } from './modules/ride-types/ride-types.module';
|
||||
import { DispatchModule } from './modules/dispatch/dispatch.module';
|
||||
import { WalletModule } from './modules/wallet/wallet.module';
|
||||
import { NotificationsModule } from './modules/notifications/notifications.module';
|
||||
import { PaymentsModule } from './modules/payments/payments.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -63,6 +64,7 @@ import { NotificationsModule } from './modules/notifications/notifications.modul
|
||||
RealtimeModule,
|
||||
FraudModule,
|
||||
WalletModule,
|
||||
PaymentsModule,
|
||||
TripsModule,
|
||||
DispatchModule,
|
||||
ChatModule,
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* جداول الدفع والسحب (schema الدفع منطقياً — بادئة tripz_pay_، قابلة للفصل).
|
||||
*/
|
||||
export class InitPayments1721600000000 implements MigrationInterface {
|
||||
public async up(q: QueryRunner): Promise<void> {
|
||||
await q.query(`
|
||||
CREATE TABLE IF NOT EXISTS tripz_pay_payments (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id uuid NOT NULL,
|
||||
user_id uuid NOT NULL,
|
||||
trip_id uuid,
|
||||
purpose varchar NOT NULL DEFAULT 'topup',
|
||||
provider varchar NOT NULL,
|
||||
method varchar,
|
||||
amount numeric(12,3) NOT NULL,
|
||||
currency varchar NOT NULL DEFAULT 'JOD',
|
||||
status varchar NOT NULL DEFAULT 'pending',
|
||||
tx_ref varchar,
|
||||
redirect_url varchar,
|
||||
meta jsonb NOT NULL DEFAULT '{}',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
)`);
|
||||
await q.query(`CREATE INDEX IF NOT EXISTS "IDX_tripz_pay_payments_user" ON tripz_pay_payments (tenant_id, user_id)`);
|
||||
await q.query(`CREATE INDEX IF NOT EXISTS "IDX_tripz_pay_payments_status" ON tripz_pay_payments (tenant_id, status)`);
|
||||
|
||||
await q.query(`
|
||||
CREATE TABLE IF NOT EXISTS tripz_pay_payouts (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id uuid NOT NULL,
|
||||
driver_user_id uuid NOT NULL,
|
||||
amount numeric(12,3) NOT NULL,
|
||||
currency varchar NOT NULL DEFAULT 'JOD',
|
||||
channel varchar NOT NULL,
|
||||
destination jsonb NOT NULL DEFAULT '{}',
|
||||
status varchar NOT NULL DEFAULT 'requested',
|
||||
ref varchar,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
)`);
|
||||
await q.query(`CREATE INDEX IF NOT EXISTS "IDX_tripz_pay_payouts_driver" ON tripz_pay_payouts (tenant_id, driver_user_id)`);
|
||||
}
|
||||
|
||||
public async down(q: QueryRunner): Promise<void> {
|
||||
await q.query(`DROP TABLE IF EXISTS tripz_pay_payouts`);
|
||||
await q.query(`DROP TABLE IF EXISTS tripz_pay_payments`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
export type PaymentStatus = 'pending' | 'success' | 'failed' | 'refunded';
|
||||
export type PaymentPurpose = 'topup' | 'trip';
|
||||
|
||||
/**
|
||||
* معاملة دفع. الجدول: tripz_pay_payments («schema الدفع» منطقياً — قابل للفصل).
|
||||
*/
|
||||
@Entity('pay_payments')
|
||||
@Index(['tenant_id', 'user_id'])
|
||||
@Index(['tenant_id', 'status'])
|
||||
export class Payment {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
tenant_id: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
user_id: string;
|
||||
|
||||
@Column({ type: 'uuid', nullable: true })
|
||||
trip_id: string | null;
|
||||
|
||||
@Column({ default: 'topup' })
|
||||
purpose: PaymentPurpose;
|
||||
|
||||
@Column()
|
||||
provider: string; // cash | cliq | zaincash | syriatel | mtn | shamcash | paymob | fawry
|
||||
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
method: string | null;
|
||||
|
||||
@Column({ type: 'numeric', precision: 12, scale: 3 })
|
||||
amount: number;
|
||||
|
||||
@Column({ default: 'JOD' })
|
||||
currency: string;
|
||||
|
||||
@Column({ type: 'varchar', default: 'pending' })
|
||||
status: PaymentStatus;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
tx_ref: string | null;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
redirect_url: string | null;
|
||||
|
||||
@Column({ type: 'jsonb', default: {} })
|
||||
meta: Record<string, any>;
|
||||
|
||||
@CreateDateColumn()
|
||||
created_at: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updated_at: Date;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
export type PayoutStatus = 'requested' | 'processing' | 'paid' | 'failed';
|
||||
|
||||
/**
|
||||
* طلب سحب أرباح سائق. الجدول: tripz_pay_payouts.
|
||||
* القنوات: bank | cliq | mtn | ecash | syriatel...
|
||||
*/
|
||||
@Entity('pay_payouts')
|
||||
@Index(['tenant_id', 'driver_user_id'])
|
||||
export class Payout {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
tenant_id: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
driver_user_id: string;
|
||||
|
||||
@Column({ type: 'numeric', precision: 12, scale: 3 })
|
||||
amount: number;
|
||||
|
||||
@Column({ default: 'JOD' })
|
||||
currency: string;
|
||||
|
||||
@Column()
|
||||
channel: string;
|
||||
|
||||
@Column({ type: 'jsonb', default: {} })
|
||||
destination: Record<string, any>;
|
||||
|
||||
@Column({ type: 'varchar', default: 'requested' })
|
||||
status: PayoutStatus;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
ref: string | null;
|
||||
|
||||
@CreateDateColumn()
|
||||
created_at: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updated_at: Date;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { PaymentsService } from './payments.service';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator';
|
||||
|
||||
@ApiTags('payments')
|
||||
@Controller('payments')
|
||||
export class PaymentsController {
|
||||
constructor(private readonly payments: PaymentsService) {}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('charge')
|
||||
charge(@CurrentUser() user: AuthUser, @Body() body: any) {
|
||||
return this.payments.charge(user.tenantId, {
|
||||
userId: user.userId,
|
||||
amount: Number(body.amount),
|
||||
currency: body.currency,
|
||||
provider: body.provider,
|
||||
purpose: body.purpose,
|
||||
tripId: body.tripId,
|
||||
method: body.method,
|
||||
});
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('mine')
|
||||
mine(@CurrentUser() user: AuthUser) {
|
||||
return this.payments.findMine(user.tenantId, user.userId);
|
||||
}
|
||||
|
||||
// تأكيد البوابة (async) — عام؛ التحقق من التوقيع يُضاف مع كل مزوّد.
|
||||
@Post('webhook/:provider')
|
||||
webhook(@Param('provider') provider: string, @Body() payload: any) {
|
||||
return this.payments.webhook(provider, payload);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Payment } from './entities/payment.entity';
|
||||
import { Payout } from './entities/payout.entity';
|
||||
import { PaymentsService } from './payments.service';
|
||||
import { PayoutsService } from './payouts.service';
|
||||
import { PaymentsController } from './payments.controller';
|
||||
import { PayoutsController } from './payouts.controller';
|
||||
import { WalletModule } from '../wallet/wallet.module';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Payment, Payout]), WalletModule],
|
||||
controllers: [PaymentsController, PayoutsController],
|
||||
providers: [PaymentsService, PayoutsService],
|
||||
exports: [PaymentsService, PayoutsService],
|
||||
})
|
||||
export class PaymentsModule {}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Payment, PaymentPurpose } from './entities/payment.entity';
|
||||
import { WalletService } from '../wallet/wallet.service';
|
||||
|
||||
export interface ChargeDto {
|
||||
userId: string;
|
||||
amount: number;
|
||||
currency?: string;
|
||||
provider: string; // cash | cliq | zaincash | syriatel | mtn | shamcash | paymob | fawry
|
||||
purpose?: PaymentPurpose;
|
||||
tripId?: string;
|
||||
method?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* الدفع بنمط المحوّلات (docs/07): cash فوري، والبوابات تُنشئ نية دفع + رابط تحويل،
|
||||
* ثم يؤكّدها webhook. عند النجاح: شحن المحفظة (topup) أو تسجيل دفع الرحلة (trip).
|
||||
* التكامل الحقيقي مع كل بوابة يُضاف لاحقاً بمفاتيح الدولة — البنية جاهزة.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PaymentsService {
|
||||
private readonly logger = new Logger('Payments');
|
||||
private readonly instantProviders = ['cash'];
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Payment) private readonly repo: Repository<Payment>,
|
||||
private readonly wallet: WalletService,
|
||||
) {}
|
||||
|
||||
async charge(tenantId: string, dto: ChargeDto) {
|
||||
const amount = Number(dto.amount);
|
||||
if (!(amount > 0)) throw new BadRequestException('amount must be > 0');
|
||||
if (!dto.provider) throw new BadRequestException('provider is required');
|
||||
|
||||
let payment = await this.repo.save(
|
||||
this.repo.create({
|
||||
tenant_id: tenantId,
|
||||
user_id: dto.userId,
|
||||
trip_id: dto.tripId ?? null,
|
||||
purpose: dto.purpose ?? 'topup',
|
||||
provider: dto.provider,
|
||||
method: dto.method ?? null,
|
||||
amount,
|
||||
currency: dto.currency ?? 'JOD',
|
||||
status: 'pending',
|
||||
}),
|
||||
);
|
||||
|
||||
// مزوّد فوري (كاش) — ينجح مباشرة
|
||||
if (this.instantProviders.includes(dto.provider)) {
|
||||
return { payment: await this.markSuccess(payment) };
|
||||
}
|
||||
|
||||
// بوابة خارجية — نية دفع + رابط تحويل (يؤكّده webhook لاحقاً)
|
||||
payment.redirect_url = `https://pay.${dto.provider}.gateway/checkout/${payment.id}`;
|
||||
payment = await this.repo.save(payment);
|
||||
this.logger.log(`intent ${dto.provider} payment=${payment.id} amount=${amount}`);
|
||||
return { payment, redirectUrl: payment.redirect_url };
|
||||
}
|
||||
|
||||
/** تأكيد من بوابة الدفع (async). التحقق من التوقيع يُضاف مع كل مزوّد. */
|
||||
async webhook(provider: string, payload: any) {
|
||||
const id = payload?.payment_id ?? payload?.ref ?? payload?.id;
|
||||
if (!id) throw new BadRequestException('missing payment reference');
|
||||
const payment = await this.repo.findOne({ where: { id } });
|
||||
if (!payment) throw new NotFoundException('payment not found');
|
||||
if (payment.status === 'success') return { ok: true, already: true };
|
||||
payment.tx_ref = payload?.tx_ref ?? payment.tx_ref;
|
||||
await this.markSuccess(payment);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async findMine(tenantId: string, userId: string) {
|
||||
return this.repo.find({
|
||||
where: { tenant_id: tenantId, user_id: userId },
|
||||
order: { created_at: 'DESC' },
|
||||
take: 100,
|
||||
});
|
||||
}
|
||||
|
||||
private async markSuccess(payment: Payment): Promise<Payment> {
|
||||
payment.status = 'success';
|
||||
payment.tx_ref = payment.tx_ref ?? `${payment.provider.toUpperCase()}-${payment.id.slice(0, 8)}`;
|
||||
const saved = await this.repo.save(payment);
|
||||
// شحن المحفظة عند نية topup
|
||||
if (saved.purpose === 'topup') {
|
||||
await this.wallet.credit(
|
||||
saved.tenant_id,
|
||||
saved.user_id,
|
||||
Number(saved.amount),
|
||||
'payment_topup',
|
||||
saved.id,
|
||||
);
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { PayoutsService } from './payouts.service';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator';
|
||||
|
||||
@ApiTags('payouts')
|
||||
@ApiBearerAuth()
|
||||
@Controller('payouts')
|
||||
export class PayoutsController {
|
||||
constructor(private readonly payouts: PayoutsService) {}
|
||||
|
||||
// السائق يطلب سحب أرباحه
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('request')
|
||||
request(@CurrentUser() user: AuthUser, @Body() body: any) {
|
||||
return this.payouts.request(user.tenantId, user.userId, {
|
||||
amount: Number(body.amount),
|
||||
channel: body.channel,
|
||||
currency: body.currency,
|
||||
destination: body.destination,
|
||||
});
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('mine')
|
||||
mine(@CurrentUser() user: AuthUser) {
|
||||
return this.payouts.listMine(user.tenantId, user.userId);
|
||||
}
|
||||
|
||||
// الأدمن يؤكّد/يفشل التحويل
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin', 'dispatcher')
|
||||
@Patch(':id/complete')
|
||||
complete(@CurrentUser() user: AuthUser, @Param('id') id: string, @Body('ref') ref: string) {
|
||||
return this.payouts.complete(user.tenantId, id, ref);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin', 'dispatcher')
|
||||
@Patch(':id/fail')
|
||||
fail(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
return this.payouts.fail(user.tenantId, id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Payout } from './entities/payout.entity';
|
||||
import { WalletService } from '../wallet/wallet.service';
|
||||
|
||||
export interface PayoutRequestDto {
|
||||
amount: number;
|
||||
channel: string; // bank | cliq | mtn | ecash | syriatel
|
||||
currency?: string;
|
||||
destination?: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* سحب أرباح السائق: يحجز المبلغ من المحفظة فوراً (debit)، ثم يُحوَّل خارجياً.
|
||||
* الفشل يُعيد المبلغ للمحفظة. التحويل الخارجي الفعلي يُنفَّذ يدوياً/بمزوّد لاحقاً.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PayoutsService {
|
||||
constructor(
|
||||
@InjectRepository(Payout) private readonly repo: Repository<Payout>,
|
||||
private readonly wallet: WalletService,
|
||||
) {}
|
||||
|
||||
async request(tenantId: string, driverUserId: string, dto: PayoutRequestDto) {
|
||||
const amount = Number(dto.amount);
|
||||
if (!(amount > 0)) throw new BadRequestException('amount must be > 0');
|
||||
if (!dto.channel) throw new BadRequestException('channel is required');
|
||||
|
||||
// يحجز المبلغ (يرمي لو الرصيد غير كافٍ)
|
||||
await this.wallet.debit(tenantId, driverUserId, amount, 'payout_hold');
|
||||
|
||||
return this.repo.save(
|
||||
this.repo.create({
|
||||
tenant_id: tenantId,
|
||||
driver_user_id: driverUserId,
|
||||
amount,
|
||||
currency: dto.currency ?? 'JOD',
|
||||
channel: dto.channel,
|
||||
destination: dto.destination ?? {},
|
||||
status: 'requested',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
listMine(tenantId: string, driverUserId: string) {
|
||||
return this.repo.find({
|
||||
where: { tenant_id: tenantId, driver_user_id: driverUserId },
|
||||
order: { created_at: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
private async getOr404(tenantId: string, id: string): Promise<Payout> {
|
||||
const p = await this.repo.findOne({ where: { tenant_id: tenantId, id } });
|
||||
if (!p) throw new NotFoundException('payout not found');
|
||||
return p;
|
||||
}
|
||||
|
||||
/** الأدمن يؤكّد أن المبلغ حُوِّل خارجياً. */
|
||||
async complete(tenantId: string, id: string, ref?: string) {
|
||||
const p = await this.getOr404(tenantId, id);
|
||||
if (p.status === 'paid') return p;
|
||||
p.status = 'paid';
|
||||
p.ref = ref ?? p.ref;
|
||||
return this.repo.save(p);
|
||||
}
|
||||
|
||||
/** فشل التحويل — يُعاد المبلغ للمحفظة. */
|
||||
async fail(tenantId: string, id: string) {
|
||||
const p = await this.getOr404(tenantId, id);
|
||||
if (p.status === 'paid') throw new BadRequestException('already paid');
|
||||
if (p.status !== 'failed') {
|
||||
await this.wallet.credit(tenantId, p.driver_user_id, Number(p.amount), 'payout_refund', p.id);
|
||||
}
|
||||
p.status = 'failed';
|
||||
return this.repo.save(p);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user