Files
tripz-llc/backend/src/modules/payments/payouts.service.ts
T
HamzaandClaude Opus 4.8 bb6a7bc7ec 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>
2026-07-16 19:05:49 +03:00

79 lines
2.8 KiB
TypeScript

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