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:
2026-07-16 19:05:49 +03:00
co-authored by Claude Opus 4.8
parent e0ccd2efb7
commit bb6a7bc7ec
9 changed files with 447 additions and 0 deletions
+2
View File
@@ -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);
}
}