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, @InjectRepository(ReferralCode) private readonly codes: Repository, private readonly coupons: CouponsService, private readonly credit: DriverCreditService, private readonly drivers: DriversService, private readonly tenants: TenantsService, ) {} async policy(tenantId: string, currency = 'JOD'): Promise { const t = await this.tenants.resolve(tenantId); return resolveRewardsPolicy(t?.settings, currency); } /** كود الدعوة الشخصي — يُولَّد عند أول طلب ويثبت بعدها. */ async myCode( tenantId: string, userId: string, role: ReferralParty, ): Promise { 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 { 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 { 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 { 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 { 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 { 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 { 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; } }