Files
tripz-llc/backend/src/modules/rewards/referrals.service.ts
T

295 lines
12 KiB
TypeScript

import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Referral, ReferralParty } from './entities/referral.entity';
import { ReferralCode } from './entities/referral-code.entity';
import { CouponsService, randomCode } from './coupons.service';
import { resolveRewardsPolicy, RewardsPolicy } from './rewards-policy';
import { TenantsService } from '../tenants/tenants.service';
import { DriverCreditService } from '../credit/driver-credit.service';
import { DriversService } from '../drivers/drivers.service';
/**
* نظام الإحالة بالاتجاهات الأربعة (المجموعة L).
*
* شكل المكافأة يتبع **دور المستفيد** لا اتجاه الإحالة:
* - سائق → رصيد تشغيلي (`referral_bonus`).
* - راكب → كوبون خصم مسقوف. **لا رصيد محفظة**: محفظة الراكب لا تُسحب
* (قانون ثابت)، فمكافأة تسويقية فيها تصير التزاماً نقدياً بلا مقابل.
*
* والاستحقاق **بعد رحلة مكتملة** لا عند التسجيل — بدون هذا الشرط خمسون
* رقماً وهمياً تساوي خمسين مكافأة، وهو بالضبط ما يأكل ميزانية التسويق.
*/
@Injectable()
export class ReferralsService {
private readonly logger = new Logger('Referrals');
constructor(
@InjectRepository(Referral) private readonly referrals: Repository<Referral>,
@InjectRepository(ReferralCode) private readonly codes: Repository<ReferralCode>,
private readonly coupons: CouponsService,
private readonly credit: DriverCreditService,
private readonly drivers: DriversService,
private readonly tenants: TenantsService,
) {}
async policy(tenantId: string, currency = 'JOD'): Promise<RewardsPolicy> {
const t = await this.tenants.resolve(tenantId);
return resolveRewardsPolicy(t?.settings, currency);
}
/** كود الدعوة الشخصي — يُولَّد عند أول طلب ويثبت بعدها. */
async myCode(
tenantId: string,
userId: string,
role: ReferralParty,
): Promise<ReferralCode> {
const existing = await this.codes.findOne({
where: { tenant_id: tenantId, user_id: userId },
});
if (existing) return existing;
for (let attempt = 0; attempt < 5; attempt++) {
try {
return await this.codes.save(
this.codes.create({
tenant_id: tenantId,
user_id: userId,
code: randomCode(6),
role,
}),
);
} catch (e: any) {
if (e?.code !== '23505' && e?.driverError?.code !== '23505') throw e;
// إمّا تصادم كود (نعيد التوليد) أو نداءان متزامنان لنفس المستخدم
// (الفريد على user_id) — والثاني يعني أن الكود صار موجوداً.
const mine = await this.codes.findOne({
where: { tenant_id: tenantId, user_id: userId },
});
if (mine) return mine;
}
}
throw new BadRequestException('could not allocate referral code');
}
/**
* يسجّل إحالة عند تسجيل مستخدم جديد بكود دعوة. `pending` — لا تُصرف الآن.
*
* **لا يرمي عند كود خاطئ**: هذا يُنادى داخل مسار التسجيل، وفشل الإحالة
* يجب ألّا يمنع مستخدماً من دخول التطبيق. يعيد `null` ويسجّل السبب.
*/
async register(
tenantId: string,
refereeUserId: string,
refereeRole: ReferralParty,
code: string,
currency = 'JOD',
): Promise<Referral | null> {
const normalized = CouponsService.normalize(code);
if (!normalized) return null;
const owner = await this.codes.findOne({
where: { tenant_id: tenantId, code: normalized, status: 'active' },
});
if (!owner) {
this.logger.warn(`unknown referral code ${normalized}`);
return null;
}
if (owner.user_id === refereeUserId) {
this.logger.warn(`self-referral blocked for ${refereeUserId}`);
return null;
}
try {
return await this.referrals.save(
this.referrals.create({
tenant_id: tenantId,
referrer_user_id: owner.user_id,
referee_user_id: refereeUserId,
referrer_role: owner.role,
referee_role: refereeRole,
code: normalized,
status: 'pending',
currency,
}),
);
} catch (e: any) {
// الفريد على referee: هذا المستخدم مُحال سلفاً — يُحال مرة واحدة فقط.
if (e?.code === '23505' || e?.driverError?.code === '23505') {
this.logger.warn(`user ${refereeUserId} already referred — ignoring ${normalized}`);
return null;
}
throw e;
}
}
/**
* يُنادى عند إتمام رحلة: يزيد عدّاد المُحال، ويؤهّله إن بلغ الشرط.
*
* الترقية `pending → qualified` بـUPDATE شرطي على الحالة، فلا ترقية
* مزدوجة لو تزامن نداءان (رحلتان تُنهيان في اللحظة نفسها).
*/
async onTripCompleted(
tenantId: string,
riderOrDriverUserId: string,
tripId: string,
): Promise<void> {
const referral = await this.referrals.findOne({
where: { tenant_id: tenantId, referee_user_id: riderOrDriverUserId, status: 'pending' },
});
if (!referral) return;
const { qualifyingTrips } = await this.policy(tenantId, referral.currency);
// خطوتان لا عبارة واحدة بـCASE: حساب العتبة **في TypeScript** لا داخل
// SQL. المحاولة الأولى وضعت `CASE WHEN qualifying_trips + 1 >= n` فجمع
// المحرّك العمود نصّاً ('0' + 1 = '01') ولم تتحقق الشرطية أبداً —
// فبقيت كل إحالة معلّقة بلا مكافأة، وهو فشل صامت لا يُكتشف إلا بشكوى.
// والتزامن محفوظ: كلتا العبارتين مشروطتان بالحالة، فرحلتان تنتهيان معاً
// لا تؤهّلان مرتين.
const bumped = await this.referrals
.createQueryBuilder()
.update(Referral)
.set({ qualifying_trips: () => 'qualifying_trips + 1' })
.where('id = :id AND tenant_id = :tenant AND status = :pending', {
id: referral.id,
tenant: tenantId,
pending: 'pending',
})
.returning(['qualifying_trips'])
.execute();
if (!bumped.affected) return;
const count = Number(bumped.raw?.[0]?.qualifying_trips ?? NaN);
if (!Number.isFinite(count) || count < qualifyingTrips) return;
const promoted = await this.referrals
.createQueryBuilder()
.update(Referral)
.set({ status: 'qualified', qualified_trip_id: tripId, qualified_at: new Date() })
.where('id = :id AND tenant_id = :tenant AND status = :pending', {
id: referral.id,
tenant: tenantId,
pending: 'pending',
})
.execute();
if (!promoted.affected) return; // سبقنا نداء متزامن — هو من سيصرف
// الصرف الفوري محاولةٌ أولى؛ الماسح شبكة الأمان لو فشلت هنا.
const fresh = await this.referrals.findOne({ where: { id: referral.id } });
if (fresh?.status === 'qualified') {
await this.payout(fresh).catch((e) =>
this.logger.error(`immediate payout failed for ${fresh.id}: ${e?.message}`),
);
}
}
/**
* يصرف مكافأتَي الطرفين ثم يعلّم الإحالة مدفوعة.
*
* الترتيب مقصود: **الصرف أولاً ثم التعليم**. لو عُلّمت مدفوعة قبل الصرف
* وسقطت العملية بينهما، ضاعت المكافأة بلا أثر ولا إعادة محاولة. والعكس
* (صرف ثم سقوط قبل التعليم) يُصلحه الماسح، والحارس الفريد على الدفتر
* يمنع الصرف مرتين.
*/
async payout(referral: Referral): Promise<void> {
const policy = await this.policy(referral.tenant_id, referral.currency);
await this.reward(referral, referral.referrer_user_id, policy, 'referrer');
await this.reward(referral, referral.referee_user_id, policy, 'referee');
await this.referrals
.createQueryBuilder()
.update(Referral)
.set({ status: 'paid', paid_at: new Date() })
.where('id = :id AND status = :qualified', { id: referral.id, qualified: 'qualified' })
.execute();
}
/** الإحالات المؤهَّلة غير المصروفة — مدخل الماسح. */
findQualified(limit = 50): Promise<Referral[]> {
return this.referrals.find({
where: { status: 'qualified' },
order: { qualified_at: 'ASC' },
take: limit,
});
}
/** ملخّص «دعواتي» — للتطبيق. */
async summary(tenantId: string, userId: string, role: ReferralParty) {
const code = await this.myCode(tenantId, userId, role);
const mine = await this.referrals.find({
where: { tenant_id: tenantId, referrer_user_id: userId },
order: { created_at: 'DESC' },
take: 100,
});
return {
code: code.code,
total: mine.length,
pending: mine.filter((r) => r.status === 'pending').length,
paid: mine.filter((r) => r.status === 'paid').length,
};
}
// ---- داخلي ----
/**
* يوجّه المكافأة حسب دور المستفيد: سائق ← رصيد · راكب ← كوبون.
*
* الدور يُحسم **وقت الصرف** من وجود صفّ سائق، لا من `referee_role` المخزَّن
* وقت التسجيل: من سجّل بكود دعوة ثم تقدّم سائقاً يستحقّ رصيداً تشغيلياً لا
* كوبون خصم. الدور المخزَّن يبقى للتقارير («من أين جاء سائقونا؟»).
*/
private async reward(
referral: Referral,
userId: string,
policy: RewardsPolicy,
side: 'referrer' | 'referee',
): Promise<void> {
const driver = await this.drivers.findByUser(referral.tenant_id, userId);
if (driver) {
await this.credit.grantReferralBonus(
referral.tenant_id,
driver.id,
policy.driverReferralBonus,
// الطرفان في إحالة واحدة، فالمعرّف وحده يجعل قيديهما متطابقين تحت
// الفهرس الفريد فيُرفض ثانيهما — اللاحقة تفصلهما.
`${referral.id}:${side}`,
referral.currency,
);
return;
}
if (!(policy.riderReferralCoupon > 0)) return;
await this.coupons.grant({
tenantId: referral.tenant_id,
userId,
amount: policy.riderReferralCoupon,
currency: referral.currency,
source: 'referral',
validDays: policy.referralCouponDays,
codePrefix: 'REF',
ref: `${referral.id}:${side}`,
});
}
/**
* صرف الإحالات المؤهَّلة (O5 — يُستدعى من CronWorker).
* شبكة أمان: ما فُصِل يُعاد محاولته في الدورة القادمة.
* @returns عدد الإحالات التي نجح صرفها.
*/
async sweep(batchSize = 50): Promise<number> {
const due = await this.findQualified(batchSize);
let paid = 0;
for (const referral of due) {
try {
await this.payout(referral);
paid++;
} catch (e: any) {
this.logger.error(`sweep payout failed for ${referral.id}: ${e?.message}`);
}
}
return paid;
}
}