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
@@ -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);
}
}