From bb6a7bc7ecdfe18f5fb75d0a7df7064c90dd6dac Mon Sep 17 00:00:00 2001 From: Hamza Date: Thu, 16 Jul 2026 19:05:49 +0300 Subject: [PATCH] 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 --- backend/src/app.module.ts | 2 + .../migrations/1721600000000-InitPayments.ts | 50 ++++++++++ .../payments/entities/payment.entity.ts | 64 ++++++++++++ .../payments/entities/payout.entity.ts | 51 ++++++++++ .../modules/payments/payments.controller.ts | 39 ++++++++ .../src/modules/payments/payments.module.ts | 17 ++++ .../src/modules/payments/payments.service.ts | 99 +++++++++++++++++++ .../modules/payments/payouts.controller.ts | 47 +++++++++ .../src/modules/payments/payouts.service.ts | 78 +++++++++++++++ 9 files changed, 447 insertions(+) create mode 100644 backend/src/database/migrations/1721600000000-InitPayments.ts create mode 100644 backend/src/modules/payments/entities/payment.entity.ts create mode 100644 backend/src/modules/payments/entities/payout.entity.ts create mode 100644 backend/src/modules/payments/payments.controller.ts create mode 100644 backend/src/modules/payments/payments.module.ts create mode 100644 backend/src/modules/payments/payments.service.ts create mode 100644 backend/src/modules/payments/payouts.controller.ts create mode 100644 backend/src/modules/payments/payouts.service.ts diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 9c1acbf..3d4bbff 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -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, diff --git a/backend/src/database/migrations/1721600000000-InitPayments.ts b/backend/src/database/migrations/1721600000000-InitPayments.ts new file mode 100644 index 0000000..72c2850 --- /dev/null +++ b/backend/src/database/migrations/1721600000000-InitPayments.ts @@ -0,0 +1,50 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * جداول الدفع والسحب (schema الدفع منطقياً — بادئة tripz_pay_، قابلة للفصل). + */ +export class InitPayments1721600000000 implements MigrationInterface { + public async up(q: QueryRunner): Promise { + 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 { + await q.query(`DROP TABLE IF EXISTS tripz_pay_payouts`); + await q.query(`DROP TABLE IF EXISTS tripz_pay_payments`); + } +} diff --git a/backend/src/modules/payments/entities/payment.entity.ts b/backend/src/modules/payments/entities/payment.entity.ts new file mode 100644 index 0000000..bd26c83 --- /dev/null +++ b/backend/src/modules/payments/entities/payment.entity.ts @@ -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; + + @CreateDateColumn() + created_at: Date; + + @UpdateDateColumn() + updated_at: Date; +} diff --git a/backend/src/modules/payments/entities/payout.entity.ts b/backend/src/modules/payments/entities/payout.entity.ts new file mode 100644 index 0000000..6bd0476 --- /dev/null +++ b/backend/src/modules/payments/entities/payout.entity.ts @@ -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; + + @Column({ type: 'varchar', default: 'requested' }) + status: PayoutStatus; + + @Column({ type: 'varchar', nullable: true }) + ref: string | null; + + @CreateDateColumn() + created_at: Date; + + @UpdateDateColumn() + updated_at: Date; +} diff --git a/backend/src/modules/payments/payments.controller.ts b/backend/src/modules/payments/payments.controller.ts new file mode 100644 index 0000000..006d4fa --- /dev/null +++ b/backend/src/modules/payments/payments.controller.ts @@ -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); + } +} diff --git a/backend/src/modules/payments/payments.module.ts b/backend/src/modules/payments/payments.module.ts new file mode 100644 index 0000000..bb02b61 --- /dev/null +++ b/backend/src/modules/payments/payments.module.ts @@ -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 {} diff --git a/backend/src/modules/payments/payments.service.ts b/backend/src/modules/payments/payments.service.ts new file mode 100644 index 0000000..09bee06 --- /dev/null +++ b/backend/src/modules/payments/payments.service.ts @@ -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, + 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.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; + } +} diff --git a/backend/src/modules/payments/payouts.controller.ts b/backend/src/modules/payments/payouts.controller.ts new file mode 100644 index 0000000..999f9ab --- /dev/null +++ b/backend/src/modules/payments/payouts.controller.ts @@ -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); + } +} diff --git a/backend/src/modules/payments/payouts.service.ts b/backend/src/modules/payments/payouts.service.ts new file mode 100644 index 0000000..9081b0f --- /dev/null +++ b/backend/src/modules/payments/payouts.service.ts @@ -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; +} + +/** + * سحب أرباح السائق: يحجز المبلغ من المحفظة فوراً (debit)، ثم يُحوَّل خارجياً. + * الفشل يُعيد المبلغ للمحفظة. التحويل الخارجي الفعلي يُنفَّذ يدوياً/بمزوّد لاحقاً. + */ +@Injectable() +export class PayoutsService { + constructor( + @InjectRepository(Payout) private readonly repo: Repository, + 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 { + 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); + } +}