295 lines
12 KiB
TypeScript
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;
|
|
}
|
|
}
|