feat: implement rewards module with referral/coupon systems, update tariff seeding, and add trip distance service for improved billing accuracy.
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
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}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user