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 { DispatchModule } from './modules/dispatch/dispatch.module';
|
||||||
import { WalletModule } from './modules/wallet/wallet.module';
|
import { WalletModule } from './modules/wallet/wallet.module';
|
||||||
import { NotificationsModule } from './modules/notifications/notifications.module';
|
import { NotificationsModule } from './modules/notifications/notifications.module';
|
||||||
|
import { PaymentsModule } from './modules/payments/payments.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -63,6 +64,7 @@ import { NotificationsModule } from './modules/notifications/notifications.modul
|
|||||||
RealtimeModule,
|
RealtimeModule,
|
||||||
FraudModule,
|
FraudModule,
|
||||||
WalletModule,
|
WalletModule,
|
||||||
|
PaymentsModule,
|
||||||
TripsModule,
|
TripsModule,
|
||||||
DispatchModule,
|
DispatchModule,
|
||||||
ChatModule,
|
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