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:
@@ -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