diff --git a/backend/e2e-tester/seed-tariffs.js b/backend/e2e-tester/seed-tariffs.js deleted file mode 100644 index 67ba040..0000000 --- a/backend/e2e-tester/seed-tariffs.js +++ /dev/null @@ -1,165 +0,0 @@ -/** - * Tripz - Global Tariffs Seed Script - * - * This script seeds the `tripz_tariffs` table with internationally recognized - * service classes (e.g. Economy, Comfort, XL/Van, Saver) and standard time windows. - * - * It maps legacy flat pricing models into the modern Tripz JSON-based TariffDefinition. - */ - -const fs = require('fs'); - -// Standard, internationally recognized service classes -const ServiceClasses = { - ECONOMY: 'economy', // Standard, affordable everyday rides (formerly 'speed') - COMFORT: 'comfort', // Newer, more spacious vehicles - LADY: 'lady', // Female drivers for female riders - ELECTRIC: 'electric', // EV/Green vehicles - VAN: 'van', // High capacity (XL) vehicles - DELIVERY: 'delivery', // Package / courier delivery - VIP: 'vip', // Premium / Luxury rides (formerly 'mishwarVip') - FIXED: 'fixed', // Fixed route/fare rides - SAVER: 'saver' // Discounted / shared rides (formerly 'awfar') -}; - -// Global pricing configurations per country -const countryConfigs = [ - { - country: 'jordan', - currency: 'JOD', - commissionPercent: 14.0, - baseFare: 0.0, - // Note: In the legacy system, Jordan's per-minute and per-km values were structurally swapped. - // We have normalized them here to follow standard logic (perKm is higher than perMin). - serviceRates: { - [ServiceClasses.ECONOMY]: { perKm: 0.184, perMin: 0.043 }, - [ServiceClasses.COMFORT]: { perKm: 0.212, perMin: 0.056 }, - [ServiceClasses.LADY]: { perKm: 0.184, perMin: 0.047 }, - [ServiceClasses.ELECTRIC]: { perKm: 0.184, perMin: 0.052 }, - [ServiceClasses.VAN]: { perKm: 0.250, perMin: 0.065 }, // Adjusted perKm for Van proportionality - [ServiceClasses.DELIVERY]: { perKm: 0.150, perMin: 0.039 }, - [ServiceClasses.VIP]: { perKm: 0.300, perMin: 0.060 }, - [ServiceClasses.FIXED]: { perKm: 0.184, perMin: 0.043 }, - [ServiceClasses.SAVER]: { perKm: 0.150, perMin: 0.037 }, - }, - timeMultipliers: { - normal: 1.0, // 08:00 - 16:00 - peak: 1.15, // 16:00 - 22:00 (15% surge) - late: 1.0 // 22:00 - 08:00 - } - }, - { - country: 'egypt', - currency: 'EGP', - commissionPercent: 12.0, - baseFare: 0.0, - serviceRates: { - [ServiceClasses.ECONOMY]: { perKm: 3.5, perMin: 0.8 }, - [ServiceClasses.COMFORT]: { perKm: 5.0, perMin: 0.9 }, - [ServiceClasses.LADY]: { perKm: 9.0, perMin: 1.0 }, - [ServiceClasses.ELECTRIC]: { perKm: 0.8, perMin: 0.5 }, - [ServiceClasses.VAN]: { perKm: 1.4, perMin: 1.0 }, - [ServiceClasses.DELIVERY]: { perKm: 1.3, perMin: 0.8 }, - [ServiceClasses.VIP]: { perKm: 5.5, perMin: 1.2 }, - [ServiceClasses.FIXED]: { perKm: 5.5, perMin: 1.0 }, - [ServiceClasses.SAVER]: { perKm: 3.0, perMin: 0.7 }, - }, - timeMultipliers: { - normal: 1.0, - peak: 1.75, // 1.4 EGP peak / 0.8 EGP normal = 1.75x - late: 1.87 // 1.5 EGP late / 0.8 EGP normal = 1.87x - } - }, - { - country: 'syria', - currency: 'SYP', - commissionPercent: 11.0, - baseFare: 0.0, - serviceRates: { - [ServiceClasses.ECONOMY]: { perKm: 40, perMin: 9 }, - [ServiceClasses.COMFORT]: { perKm: 44, perMin: 10 }, - [ServiceClasses.LADY]: { perKm: 63, perMin: 12 }, - [ServiceClasses.ELECTRIC]: { perKm: 9, perMin: 5 }, - [ServiceClasses.VAN]: { perKm: 10, perMin: 8 }, - [ServiceClasses.DELIVERY]: { perKm: 20, perMin: 9 }, - [ServiceClasses.VIP]: { perKm: 26, perMin: 12 }, - [ServiceClasses.FIXED]: { perKm: 26, perMin: 10 }, - [ServiceClasses.SAVER]: { perKm: 36, perMin: 8 }, - }, - timeMultipliers: { - normal: 1.0, - peak: 1.11, // 10 peak / 9 normal = 1.11x - late: 1.22 // 11 late / 9 normal = 1.22x - } - } -]; - -function generateSql() { - let sql = `-- Tripz Standard Tariffs Seed Script\n`; - sql += `-- Generated with international ride-hailing naming conventions.\n\n`; - - for (const config of countryConfigs) { - const city = config.country; - - for (const [serviceClass, rates] of Object.entries(config.serviceRates)) { - - const definition = { - currency: config.currency, - mode: 'time_and_distance', - booking_fee: config.baseFare, - min_fare: config.baseFare > 0 ? config.baseFare : (rates.perKm * 2), // Min fare = 2 KMs default - windows: [ - { - name: 'normal', - from: '08:00', - to: '16:00', - flag: config.baseFare, - per_km: rates.perKm, - per_min: rates.perMin, - per_min_waiting: Number((rates.perMin * 1.5).toFixed(3)) - }, - { - name: 'peak', - from: '16:00', - to: '22:00', - flag: config.baseFare, - per_km: Number((rates.perKm * config.timeMultipliers.peak).toFixed(3)), - per_min: Number((rates.perMin * config.timeMultipliers.peak).toFixed(3)), - per_min_waiting: Number((rates.perMin * config.timeMultipliers.peak * 1.5).toFixed(3)) - }, - { - name: 'late', - from: '22:00', - to: '08:00', - flag: config.baseFare, - per_km: Number((rates.perKm * config.timeMultipliers.late).toFixed(3)), - per_min: Number((rates.perMin * config.timeMultipliers.late).toFixed(3)), - per_min_waiting: Number((rates.perMin * config.timeMultipliers.late * 1.5).toFixed(3)) - } - ], - commission: { - percent: config.commissionPercent - } - }; - - const defJson = JSON.stringify(definition).replace(/'/g, "''"); // Escape single quotes for SQL - - sql += `INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition)\n`; - sql += `VALUES (\n`; - sql += ` gen_random_uuid(),\n`; - sql += ` (SELECT id FROM tripz_tenants WHERE slug = 'siro' LIMIT 1),\n`; - sql += ` '${city}',\n`; - sql += ` '${serviceClass}',\n`; - sql += ` 1,\n`; - sql += ` true,\n`; - sql += ` '${defJson}'::jsonb\n`; - sql += `);\n\n`; - } - } - - return sql; -} - -const sqlOutput = generateSql(); -fs.writeFileSync(__dirname + '/seed-tariffs.sql', sqlOutput); -console.log('✅ Generated seed-tariffs.sql successfully with international naming standards.'); diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 6b6e40c..a724daf 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -36,6 +36,7 @@ import { DispatchModule } from './modules/dispatch/dispatch.module'; import { WalletModule } from './modules/wallet/wallet.module'; import { TenantWalletModule } from './modules/tenant-wallet/tenant-wallet.module'; import { CreditModule } from './modules/credit/credit.module'; +import { RewardsModule } from './modules/rewards/rewards.module'; import { NotificationsModule } from './modules/notifications/notifications.module'; import { PaymentsModule } from './modules/payments/payments.module'; import { StorageModule } from './common/storage/storage.module'; @@ -100,6 +101,7 @@ import { GeofenceModule } from './modules/geofence/geofence.module'; WalletModule, TenantWalletModule, // دفترا المستأجر: إيراد وأمانات (docs/24) CreditModule, + RewardsModule, // الإحالات والكوبونات (المجموعة L) PaymentsModule, BillingModule, TripsModule, diff --git a/backend/src/common/seed/seed.service.ts b/backend/src/common/seed/seed.service.ts index d7b6eb1..d1bcc6a 100644 --- a/backend/src/common/seed/seed.service.ts +++ b/backend/src/common/seed/seed.service.ts @@ -2,6 +2,11 @@ import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { TenantsService } from '../../modules/tenants/tenants.service'; import { TariffService } from '../../modules/tariff/tariff.service'; import { RideTypesService } from '../../modules/ride-types/ride-types.service'; +import { + buildDefinition, + countryTariff, + SERVICE_CLASSES, +} from '../../modules/tariff/default-tariffs'; /** * seed تلقائي عند الإقلاع: مستأجر تجريبي (Tenant Zero) + تعرفة افتراضية، @@ -41,30 +46,25 @@ export class SeedService implements OnModuleInit { this.logger.log(`seeded demo tenant "siro" (${tenant.id})`); } - const existing = await this.tariff.getActive(tenant.id, 'default', 'economy'); - if (!existing) { + // تعرفة الانطلاق **لكل فئة خدمة** من مصدر الحقيقة الواحد + // (`default-tariffs.ts`)، لا الاقتصادي وحده: طلب فئة بلا تعرفة كان + // يمرّ بلا سعر ثم يُسوّى بصفر — رحلة مجانية وعمولة صفر. + // البلد من `country_pack` للمستأجر، والمدينة `'default'` لأنها القيمة + // التي يبحث بها `trips.service` فعلاً. + const country = countryTariff(tenant.countryPack); + for (const serviceClass of Object.values(SERVICE_CLASSES)) { + const existing = await this.tariff.getActive(tenant.id, 'default', serviceClass); + if (existing) continue; await this.tariff.create({ tenant_id: tenant.id, city: 'default', - service_class: 'economy', + service_class: serviceClass, version: 1, active: true, - definition: { - currency: 'JOD', - rounding: { increment: 0.05, mode: 'nearest' }, - mode: 'time_or_distance', - speed_threshold_kmh: 18, - windows: [ - { name: 'day', from: '06:00', to: '22:00', flag: 0.39, per_km: 0.28, per_min: 0.06, per_min_waiting: 0.48 }, - { name: 'night', from: '22:00', to: '06:00', flag: 0.40, per_km: 0.33, per_min: 0.07, per_min_waiting: 0.55 }, - ], - booking_fee: 0.25, - min_fare: 1.0, - surge: { enabled: false }, - }, + definition: buildDefinition(country, serviceClass), }); - this.logger.log('seeded default tariff for "siro"'); } + this.logger.log(`seeded ${Object.keys(SERVICE_CLASSES).length} tariffs for "${tenant.slug}" (${country.currency})`); // أنواع الرحلات الافتراضية const types: any[] = [ diff --git a/backend/src/config/configuration.ts b/backend/src/config/configuration.ts index f3427d9..e9a7ac4 100644 --- a/backend/src/config/configuration.ts +++ b/backend/src/config/configuration.ts @@ -82,6 +82,12 @@ export default () => ({ scheduleSweepMs: parseInt(process.env.TRIP_SCHEDULE_SWEEP_MS ?? '30000', 10), }, + // المكافآت: كل كم يمسح الماسح الإحالات المؤهَّلة غير المصروفة (docs/17 — L5). + // أبطأ من كنس الرحلات عمداً: هذا شبكة أمان لا مسار أساسي. + rewards: { + sweepMs: parseInt(process.env.REWARDS_SWEEP_MS ?? '60000', 10), + }, + nabeh: { baseUrl: process.env.NABEH_BASE_URL ?? 'https://nabeh.intaleqapp.com', email: process.env.NABEH_EMAIL ?? '', diff --git a/backend/src/database/migrations/1721950000000-ReferralsAndCoupons.ts b/backend/src/database/migrations/1721950000000-ReferralsAndCoupons.ts new file mode 100644 index 0000000..7c2364c --- /dev/null +++ b/backend/src/database/migrations/1721950000000-ReferralsAndCoupons.ts @@ -0,0 +1,169 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * الإحالات والكوبونات (المجموعة L — قرار المالك 2026-07-18). + * + * مكافأة الراكب كوبون لا رصيد محفظة: محفظة الراكب لا تُسحب أبداً (قانون + * ثابت)، فإنزال مكافأة تسويقية فيها يخلق التزاماً نقدياً بلا مقابل. + * والحدود هنا مفروضة على القاعدة لا بفحص تطبيقي وحده — الفحص التطبيقي + * يسقط تحت التزامن، والقيد لا يسقط. + */ +export class ReferralsAndCoupons1721950000000 implements MigrationInterface { + public async up(q: QueryRunner): Promise { + await q.query(` + CREATE TABLE IF NOT EXISTS tripz_coupons ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id uuid NOT NULL, + code varchar NOT NULL, + user_id uuid, + discount_type varchar NOT NULL DEFAULT 'percent', + discount_value numeric(12,3) NOT NULL, + max_discount numeric(12,3), + min_fare numeric(12,3), + currency varchar NOT NULL DEFAULT 'JOD', + valid_from TIMESTAMP, + valid_until TIMESTAMP, + max_redemptions int, + max_per_user int NOT NULL DEFAULT 1, + redeemed_count int NOT NULL DEFAULT 0, + status varchar NOT NULL DEFAULT 'active', + source varchar NOT NULL DEFAULT 'campaign', + ref varchar, + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now(), + -- خصم سالب كان سيرفع الأجرة؛ ونسبة فوق 100 تجعلها سالبة. + CONSTRAINT chk_tripz_coupons_value CHECK ( + discount_value >= 0 + AND (discount_type <> 'percent' OR discount_value <= 100) + ), + CONSTRAINT chk_tripz_coupons_cap CHECK (max_discount IS NULL OR max_discount >= 0) + ) + `); + await q.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "IDX_tripz_coupons_tenant_code" + ON tripz_coupons (tenant_id, code) + `); + await q.query(` + CREATE INDEX IF NOT EXISTS "IDX_tripz_coupons_tenant_user" + ON tripz_coupons (tenant_id, user_id) + `); + + // كوبون واحد لكل سبب منح — يمنع مكافأة إحالة مزدوجة إذا التقى الصرف + // الفوري بالماسح على نفس الإحالة. + await q.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "IDX_tripz_coupons_ref_once" + ON tripz_coupons (tenant_id, ref) + WHERE ref IS NOT NULL + `); + + await q.query(` + CREATE TABLE IF NOT EXISTS tripz_coupon_redemptions ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id uuid NOT NULL, + coupon_id uuid NOT NULL, + user_id uuid NOT NULL, + trip_id uuid NOT NULL, + discount_amount numeric(12,3) NOT NULL, + fare_before numeric(12,3), + currency varchar NOT NULL DEFAULT 'JOD', + created_at TIMESTAMP NOT NULL DEFAULT now() + ) + `); + // كوبون واحد لكل رحلة — لا تكديس أكواد على أجرة واحدة. + await q.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "IDX_tripz_coupon_redemptions_trip_once" + ON tripz_coupon_redemptions (tenant_id, trip_id) + `); + await q.query(` + CREATE INDEX IF NOT EXISTS "IDX_tripz_coupon_redemptions_coupon" + ON tripz_coupon_redemptions (tenant_id, coupon_id) + `); + await q.query(` + CREATE INDEX IF NOT EXISTS "IDX_tripz_coupon_redemptions_user" + ON tripz_coupon_redemptions (tenant_id, user_id) + `); + + await q.query(` + CREATE TABLE IF NOT EXISTS tripz_referral_codes ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id uuid NOT NULL, + user_id uuid NOT NULL, + code varchar NOT NULL, + role varchar NOT NULL, + status varchar NOT NULL DEFAULT 'active', + created_at TIMESTAMP NOT NULL DEFAULT now() + ) + `); + await q.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "IDX_tripz_referral_codes_tenant_code" + ON tripz_referral_codes (tenant_id, code) + `); + await q.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "IDX_tripz_referral_codes_tenant_user" + ON tripz_referral_codes (tenant_id, user_id) + `); + + await q.query(` + CREATE TABLE IF NOT EXISTS tripz_referrals ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id uuid NOT NULL, + referrer_user_id uuid NOT NULL, + referee_user_id uuid NOT NULL, + referrer_role varchar NOT NULL, + referee_role varchar NOT NULL, + code varchar NOT NULL, + status varchar NOT NULL DEFAULT 'pending', + qualifying_trips int NOT NULL DEFAULT 0, + qualified_trip_id uuid, + qualified_at TIMESTAMP, + paid_at TIMESTAMP, + rejected_reason varchar, + currency varchar NOT NULL DEFAULT 'JOD', + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now(), + -- إحالة النفس: أوضح صور الاحتيال وأرخصها منعاً. + CONSTRAINT chk_tripz_referrals_not_self CHECK (referrer_user_id <> referee_user_id) + ) + `); + // الحارس الأساسي: الشخص يُحال **مرة واحدة** لا مرة لكل داعٍ. + await q.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "IDX_tripz_referrals_referee_once" + ON tripz_referrals (tenant_id, referee_user_id) + `); + await q.query(` + CREATE INDEX IF NOT EXISTS "IDX_tripz_referrals_referrer" + ON tripz_referrals (tenant_id, referrer_user_id) + `); + // الماسح يقرأ بالحالة كل دورة — بلا فهرس يمسح الجدول كله مع نموّه. + await q.query(` + CREATE INDEX IF NOT EXISTS "IDX_tripz_referrals_status" + ON tripz_referrals (tenant_id, status) + `); + + // أثر الكوبون على الرحلة — نصاً لا مفتاحاً أجنبياً: الرحلة سجلّ تاريخي، + // وحذف حملة قديمة يجب ألّا يمحو سبب الخصم من رحلة مضت. + await q.query(` + ALTER TABLE tripz_trips ADD COLUMN IF NOT EXISTS coupon_code varchar + `); + await q.query(` + ALTER TABLE tripz_trips ADD COLUMN IF NOT EXISTS coupon_discount numeric(12,3) + `); + + // مكافأة الإحالة مرة واحدة لكل سائق لكل إحالة — نفس حارس J6 على القاعدة. + await q.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "IDX_tripz_credit_txns_referral_once" + ON tripz_credit_txns (tenant_id, ref) + WHERE type = 'referral_bonus' + `); + } + + public async down(q: QueryRunner): Promise { + await q.query(`DROP INDEX IF EXISTS "IDX_tripz_credit_txns_referral_once"`); + await q.query(`ALTER TABLE tripz_trips DROP COLUMN IF EXISTS coupon_discount`); + await q.query(`ALTER TABLE tripz_trips DROP COLUMN IF EXISTS coupon_code`); + await q.query(`DROP TABLE IF EXISTS tripz_referrals`); + await q.query(`DROP TABLE IF EXISTS tripz_referral_codes`); + await q.query(`DROP TABLE IF EXISTS tripz_coupon_redemptions`); + await q.query(`DROP TABLE IF EXISTS tripz_coupons`); + } +} diff --git a/backend/src/database/migrations/1721960000000-ActualDistanceBilling.ts b/backend/src/database/migrations/1721960000000-ActualDistanceBilling.ts new file mode 100644 index 0000000..640e105 --- /dev/null +++ b/backend/src/database/migrations/1721960000000-ActualDistanceBilling.ts @@ -0,0 +1,38 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * المسافة المقاسة مقابل المقدَّرة (docs/17 — B12). + * + * الأجرة تُحسب على ما قُطع فعلاً من نبضات GPS لا على تقدير وقت الطلب: + * تحويلة حقيقية يقبضها السائق، وجولة مخترَعة يقصّها السقف. الأعمدة الثلاثة + * تُخزَّن دائماً — حتى حين لا يُحاسَب عليها — لأن النسبة هي ما يُقرأ لاحقاً + * لكشف النمط: سائق نسبته 1.6 في كل رحلاته ليس ضحية ازدحام. + */ +export class ActualDistanceBilling1721960000000 implements MigrationInterface { + public async up(q: QueryRunner): Promise { + await q.query(` + ALTER TABLE tripz_trips + ADD COLUMN IF NOT EXISTS actual_distance_km numeric(10,3), + ADD COLUMN IF NOT EXISTS billed_distance_km numeric(10,3), + ADD COLUMN IF NOT EXISTS distance_ratio numeric(6,3) + `); + + // الاستعلام الحاكم في التسوية: نبضات سائق بين لحظتين. بلا هذا الفهرس + // يمسح الجدول كاملاً — وهو أسرع الجداول نموّاً عندنا (نبضة كل ثوانٍ لكل + // سائق متصل)، فالمسح يزداد كلفةً كل يوم. + await q.query(` + CREATE INDEX IF NOT EXISTS "IDX_tripz_driver_tracks_driver_time" + ON tripz_driver_tracks (tenant_id, driver_id, recorded_at) + `); + } + + public async down(q: QueryRunner): Promise { + await q.query(`DROP INDEX IF EXISTS "IDX_tripz_driver_tracks_driver_time"`); + await q.query(` + ALTER TABLE tripz_trips + DROP COLUMN IF EXISTS distance_ratio, + DROP COLUMN IF EXISTS billed_distance_km, + DROP COLUMN IF EXISTS actual_distance_km + `); + } +} diff --git a/backend/src/database/seeds/generate-tariff-sql.ts b/backend/src/database/seeds/generate-tariff-sql.ts new file mode 100644 index 0000000..34e26a2 --- /dev/null +++ b/backend/src/database/seeds/generate-tariff-sql.ts @@ -0,0 +1,60 @@ +import { writeFileSync } from 'fs'; +import { join } from 'path'; +import { + buildDefinition, + COUNTRY_TARIFFS, + SERVICE_CLASSES, +} from '../../modules/tariff/default-tariffs'; + +/** + * يولّد `tariffs.sql` لتطبيقها على قاعدة **قائمة** — للمستأجرين الموجودين + * قبل أن يصير الزرع تلقائياً. + * + * التشغيل: `npx ts-node src/database/seeds/generate-tariff-sql.ts` + * + * مستأجر جديد لا يحتاج هذا الملف إطلاقاً: `SeedService` يزرع له التعرفة من + * **نفس** `default-tariffs.ts` عند الإقلاع. هذا المولّد للحاق بالقائم فقط، + * ولذلك يقرأ من مصدر الحقيقة نفسه — نسختان من أرقام التسعير كانتا ستتباعدان. + * + * الناتج **آمن للتكرار**: كل تشغيل يطفئ الفعّالة ويُدرج `max(version)+1`، + * فلا يبقى صفّان فعّالان لنفس (مستأجر، مدينة، فئة) — و`getActive` ترتّب + * `version DESC` فكان الاختيار بينهما غير محدَّد. + */ +function generateSql(): string { + let sql = `-- Tripz — تعرفة الانطلاق الافتراضية لكل بلد.\n`; + sql += `-- مولَّد من src/database/seeds/generate-tariff-sql.ts — لا يُحرَّر يدوياً.\n`; + sql += `-- المصدر: src/modules/tariff/default-tariffs.ts\n`; + sql += `-- آمن للتكرار: كل تشغيل يطفئ الفعّالة ويُدرج نسخة أعلى.\n\n`; + sql += `BEGIN;\n\n`; + + for (const country of COUNTRY_TARIFFS) { + sql += `-- ============ ${country.countryPack.toUpperCase()} (${country.currency}) ============\n`; + + for (const serviceClass of Object.values(SERVICE_CLASSES)) { + const definition = buildDefinition(country, serviceClass); + const defJson = JSON.stringify(definition).replace(/'/g, "''"); + + // يطال **كل** مستأجر في هذا البلد لا سيرو وحده: تعرفة الانطلاق عامة، + // وربطها بمستأجر واحد كان يترك كل مستأجر آخر بلا تسعير. + sql += `-- ${serviceClass}\n`; + sql += `UPDATE tripz_tariffs SET active = false\n`; + sql += ` WHERE city = 'default' AND service_class = '${serviceClass}' AND active = true\n`; + sql += ` AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = '${country.countryPack}');\n`; + sql += `INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition)\n`; + sql += `SELECT uuid_generate_v4(), t.id, 'default', '${serviceClass}',\n`; + sql += ` COALESCE((SELECT MAX(version) FROM tripz_tariffs x\n`; + sql += ` WHERE x.tenant_id = t.id AND x.city = 'default'\n`; + sql += ` AND x.service_class = '${serviceClass}'), 0) + 1,\n`; + sql += ` true, '${defJson}'::jsonb\n`; + sql += ` FROM tripz_tenants t WHERE t.country_pack = '${country.countryPack}';\n\n`; + } + } + + sql += `COMMIT;\n`; + return sql; +} + +const out = join(__dirname, 'tariffs.sql'); +writeFileSync(out, generateSql()); +// eslint-disable-next-line no-console +console.log(`✅ generated ${out}`); diff --git a/backend/src/database/seeds/tariffs.sql b/backend/src/database/seeds/tariffs.sql new file mode 100644 index 0000000..a52b79b --- /dev/null +++ b/backend/src/database/seeds/tariffs.sql @@ -0,0 +1,335 @@ +-- Tripz — تعرفة الانطلاق الافتراضية لكل بلد. +-- مولَّد من src/database/seeds/generate-tariff-sql.ts — لا يُحرَّر يدوياً. +-- المصدر: src/modules/tariff/default-tariffs.ts +-- آمن للتكرار: كل تشغيل يطفئ الفعّالة ويُدرج نسخة أعلى. + +BEGIN; + +-- ============ JO (JOD) ============ +-- economy +UPDATE tripz_tariffs SET active = false + WHERE city = 'default' AND service_class = 'economy' AND active = true + AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'jo'); +INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition) +SELECT uuid_generate_v4(), t.id, 'default', 'economy', + COALESCE((SELECT MAX(version) FROM tripz_tariffs x + WHERE x.tenant_id = t.id AND x.city = 'default' + AND x.service_class = 'economy'), 0) + 1, + true, '{"currency":"JOD","timezone":"Asia/Amman","mode":"time_and_distance","rounding":{"increment":0.05,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":0.488,"per_km":0.35,"per_min":0.075,"per_min_waiting":0.6},{"name":"normal_day","from":"09:30","to":"16:00","flag":0.39,"per_km":0.28,"per_min":0.06,"per_min_waiting":0.48},{"name":"evening_peak","from":"16:00","to":"19:30","flag":0.488,"per_km":0.35,"per_min":0.075,"per_min_waiting":0.6},{"name":"normal_evening","from":"19:30","to":"23:00","flag":0.39,"per_km":0.28,"per_min":0.06,"per_min_waiting":0.48},{"name":"late_night","from":"23:00","to":"07:00","flag":0.448,"per_km":0.322,"per_min":0.069,"per_min_waiting":0.552}],"booking_fee":0.25,"min_fare":1.1,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":14}}'::jsonb + FROM tripz_tenants t WHERE t.country_pack = 'jo'; + +-- comfort +UPDATE tripz_tariffs SET active = false + WHERE city = 'default' AND service_class = 'comfort' AND active = true + AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'jo'); +INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition) +SELECT uuid_generate_v4(), t.id, 'default', 'comfort', + COALESCE((SELECT MAX(version) FROM tripz_tariffs x + WHERE x.tenant_id = t.id AND x.city = 'default' + AND x.service_class = 'comfort'), 0) + 1, + true, '{"currency":"JOD","timezone":"Asia/Amman","mode":"time_and_distance","rounding":{"increment":0.05,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":0.609,"per_km":0.438,"per_min":0.094,"per_min_waiting":0.6},{"name":"normal_day","from":"09:30","to":"16:00","flag":0.488,"per_km":0.35,"per_min":0.075,"per_min_waiting":0.48},{"name":"evening_peak","from":"16:00","to":"19:30","flag":0.609,"per_km":0.438,"per_min":0.094,"per_min_waiting":0.6},{"name":"normal_evening","from":"19:30","to":"23:00","flag":0.488,"per_km":0.35,"per_min":0.075,"per_min_waiting":0.48},{"name":"late_night","from":"23:00","to":"07:00","flag":0.561,"per_km":0.403,"per_min":0.086,"per_min_waiting":0.552}],"booking_fee":0.25,"min_fare":1.1,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":14}}'::jsonb + FROM tripz_tenants t WHERE t.country_pack = 'jo'; + +-- lady +UPDATE tripz_tariffs SET active = false + WHERE city = 'default' AND service_class = 'lady' AND active = true + AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'jo'); +INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition) +SELECT uuid_generate_v4(), t.id, 'default', 'lady', + COALESCE((SELECT MAX(version) FROM tripz_tariffs x + WHERE x.tenant_id = t.id AND x.city = 'default' + AND x.service_class = 'lady'), 0) + 1, + true, '{"currency":"JOD","timezone":"Asia/Amman","mode":"time_and_distance","rounding":{"increment":0.05,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":0.536,"per_km":0.385,"per_min":0.083,"per_min_waiting":0.6},{"name":"normal_day","from":"09:30","to":"16:00","flag":0.429,"per_km":0.308,"per_min":0.066,"per_min_waiting":0.48},{"name":"evening_peak","from":"16:00","to":"19:30","flag":0.536,"per_km":0.385,"per_min":0.083,"per_min_waiting":0.6},{"name":"normal_evening","from":"19:30","to":"23:00","flag":0.429,"per_km":0.308,"per_min":0.066,"per_min_waiting":0.48},{"name":"late_night","from":"23:00","to":"07:00","flag":0.493,"per_km":0.354,"per_min":0.076,"per_min_waiting":0.552}],"booking_fee":0.25,"min_fare":1.1,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":14}}'::jsonb + FROM tripz_tenants t WHERE t.country_pack = 'jo'; + +-- electric +UPDATE tripz_tariffs SET active = false + WHERE city = 'default' AND service_class = 'electric' AND active = true + AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'jo'); +INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition) +SELECT uuid_generate_v4(), t.id, 'default', 'electric', + COALESCE((SELECT MAX(version) FROM tripz_tariffs x + WHERE x.tenant_id = t.id AND x.city = 'default' + AND x.service_class = 'electric'), 0) + 1, + true, '{"currency":"JOD","timezone":"Asia/Amman","mode":"time_and_distance","rounding":{"increment":0.05,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":0.512,"per_km":0.368,"per_min":0.079,"per_min_waiting":0.6},{"name":"normal_day","from":"09:30","to":"16:00","flag":0.41,"per_km":0.294,"per_min":0.063,"per_min_waiting":0.48},{"name":"evening_peak","from":"16:00","to":"19:30","flag":0.512,"per_km":0.368,"per_min":0.079,"per_min_waiting":0.6},{"name":"normal_evening","from":"19:30","to":"23:00","flag":0.41,"per_km":0.294,"per_min":0.063,"per_min_waiting":0.48},{"name":"late_night","from":"23:00","to":"07:00","flag":0.471,"per_km":0.338,"per_min":0.072,"per_min_waiting":0.552}],"booking_fee":0.25,"min_fare":1.1,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":14}}'::jsonb + FROM tripz_tenants t WHERE t.country_pack = 'jo'; + +-- van +UPDATE tripz_tariffs SET active = false + WHERE city = 'default' AND service_class = 'van' AND active = true + AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'jo'); +INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition) +SELECT uuid_generate_v4(), t.id, 'default', 'van', + COALESCE((SELECT MAX(version) FROM tripz_tariffs x + WHERE x.tenant_id = t.id AND x.city = 'default' + AND x.service_class = 'van'), 0) + 1, + true, '{"currency":"JOD","timezone":"Asia/Amman","mode":"time_and_distance","rounding":{"increment":0.05,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":0.707,"per_km":0.508,"per_min":0.109,"per_min_waiting":0.6},{"name":"normal_day","from":"09:30","to":"16:00","flag":0.566,"per_km":0.406,"per_min":0.087,"per_min_waiting":0.48},{"name":"evening_peak","from":"16:00","to":"19:30","flag":0.707,"per_km":0.508,"per_min":0.109,"per_min_waiting":0.6},{"name":"normal_evening","from":"19:30","to":"23:00","flag":0.566,"per_km":0.406,"per_min":0.087,"per_min_waiting":0.48},{"name":"late_night","from":"23:00","to":"07:00","flag":0.65,"per_km":0.467,"per_min":0.1,"per_min_waiting":0.552}],"booking_fee":0.25,"min_fare":1.1,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":14}}'::jsonb + FROM tripz_tenants t WHERE t.country_pack = 'jo'; + +-- delivery +UPDATE tripz_tariffs SET active = false + WHERE city = 'default' AND service_class = 'delivery' AND active = true + AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'jo'); +INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition) +SELECT uuid_generate_v4(), t.id, 'default', 'delivery', + COALESCE((SELECT MAX(version) FROM tripz_tariffs x + WHERE x.tenant_id = t.id AND x.city = 'default' + AND x.service_class = 'delivery'), 0) + 1, + true, '{"currency":"JOD","timezone":"Asia/Amman","mode":"time_and_distance","rounding":{"increment":0.05,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":0.439,"per_km":0.315,"per_min":0.068,"per_min_waiting":0.6},{"name":"normal_day","from":"09:30","to":"16:00","flag":0.351,"per_km":0.252,"per_min":0.054,"per_min_waiting":0.48},{"name":"evening_peak","from":"16:00","to":"19:30","flag":0.439,"per_km":0.315,"per_min":0.068,"per_min_waiting":0.6},{"name":"normal_evening","from":"19:30","to":"23:00","flag":0.351,"per_km":0.252,"per_min":0.054,"per_min_waiting":0.48},{"name":"late_night","from":"23:00","to":"07:00","flag":0.404,"per_km":0.29,"per_min":0.062,"per_min_waiting":0.552}],"booking_fee":0.25,"min_fare":1.1,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":14}}'::jsonb + FROM tripz_tenants t WHERE t.country_pack = 'jo'; + +-- vip +UPDATE tripz_tariffs SET active = false + WHERE city = 'default' AND service_class = 'vip' AND active = true + AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'jo'); +INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition) +SELECT uuid_generate_v4(), t.id, 'default', 'vip', + COALESCE((SELECT MAX(version) FROM tripz_tariffs x + WHERE x.tenant_id = t.id AND x.city = 'default' + AND x.service_class = 'vip'), 0) + 1, + true, '{"currency":"JOD","timezone":"Asia/Amman","mode":"time_and_distance","rounding":{"increment":0.05,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":0.853,"per_km":0.613,"per_min":0.131,"per_min_waiting":0.6},{"name":"normal_day","from":"09:30","to":"16:00","flag":0.682,"per_km":0.49,"per_min":0.105,"per_min_waiting":0.48},{"name":"evening_peak","from":"16:00","to":"19:30","flag":0.853,"per_km":0.613,"per_min":0.131,"per_min_waiting":0.6},{"name":"normal_evening","from":"19:30","to":"23:00","flag":0.682,"per_km":0.49,"per_min":0.105,"per_min_waiting":0.48},{"name":"late_night","from":"23:00","to":"07:00","flag":0.785,"per_km":0.564,"per_min":0.121,"per_min_waiting":0.552}],"booking_fee":0.25,"min_fare":1.1,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":14}}'::jsonb + FROM tripz_tenants t WHERE t.country_pack = 'jo'; + +-- fixed +UPDATE tripz_tariffs SET active = false + WHERE city = 'default' AND service_class = 'fixed' AND active = true + AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'jo'); +INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition) +SELECT uuid_generate_v4(), t.id, 'default', 'fixed', + COALESCE((SELECT MAX(version) FROM tripz_tariffs x + WHERE x.tenant_id = t.id AND x.city = 'default' + AND x.service_class = 'fixed'), 0) + 1, + true, '{"currency":"JOD","timezone":"Asia/Amman","mode":"fixed_quote","rounding":{"increment":0.05,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":0.488,"per_km":0.35,"per_min":0.075,"per_min_waiting":0.6},{"name":"normal_day","from":"09:30","to":"16:00","flag":0.39,"per_km":0.28,"per_min":0.06,"per_min_waiting":0.48},{"name":"evening_peak","from":"16:00","to":"19:30","flag":0.488,"per_km":0.35,"per_min":0.075,"per_min_waiting":0.6},{"name":"normal_evening","from":"19:30","to":"23:00","flag":0.39,"per_km":0.28,"per_min":0.06,"per_min_waiting":0.48},{"name":"late_night","from":"23:00","to":"07:00","flag":0.448,"per_km":0.322,"per_min":0.069,"per_min_waiting":0.552}],"booking_fee":0,"min_fare":0,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":14},"fixed_fare":2.5}'::jsonb + FROM tripz_tenants t WHERE t.country_pack = 'jo'; + +-- saver +UPDATE tripz_tariffs SET active = false + WHERE city = 'default' AND service_class = 'saver' AND active = true + AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'jo'); +INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition) +SELECT uuid_generate_v4(), t.id, 'default', 'saver', + COALESCE((SELECT MAX(version) FROM tripz_tariffs x + WHERE x.tenant_id = t.id AND x.city = 'default' + AND x.service_class = 'saver'), 0) + 1, + true, '{"currency":"JOD","timezone":"Asia/Amman","mode":"time_and_distance","rounding":{"increment":0.05,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":0.414,"per_km":0.298,"per_min":0.064,"per_min_waiting":0.6},{"name":"normal_day","from":"09:30","to":"16:00","flag":0.332,"per_km":0.238,"per_min":0.051,"per_min_waiting":0.48},{"name":"evening_peak","from":"16:00","to":"19:30","flag":0.414,"per_km":0.298,"per_min":0.064,"per_min_waiting":0.6},{"name":"normal_evening","from":"19:30","to":"23:00","flag":0.332,"per_km":0.238,"per_min":0.051,"per_min_waiting":0.48},{"name":"late_night","from":"23:00","to":"07:00","flag":0.381,"per_km":0.274,"per_min":0.059,"per_min_waiting":0.552}],"booking_fee":0.25,"min_fare":1.1,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":14}}'::jsonb + FROM tripz_tenants t WHERE t.country_pack = 'jo'; + +-- ============ EG (EGP) ============ +-- economy +UPDATE tripz_tariffs SET active = false + WHERE city = 'default' AND service_class = 'economy' AND active = true + AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'eg'); +INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition) +SELECT uuid_generate_v4(), t.id, 'default', 'economy', + COALESCE((SELECT MAX(version) FROM tripz_tariffs x + WHERE x.tenant_id = t.id AND x.city = 'default' + AND x.service_class = 'economy'), 0) + 1, + true, '{"currency":"EGP","timezone":"Africa/Cairo","mode":"time_and_distance","rounding":{"increment":1,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":8.86,"per_km":6.36,"per_min":1.36,"per_min_waiting":10.91},{"name":"normal_day","from":"09:30","to":"16:00","flag":7.09,"per_km":5.09,"per_min":1.09,"per_min_waiting":8.73},{"name":"evening_peak","from":"16:00","to":"19:30","flag":8.86,"per_km":6.36,"per_min":1.36,"per_min_waiting":10.91},{"name":"normal_evening","from":"19:30","to":"23:00","flag":7.09,"per_km":5.09,"per_min":1.09,"per_min_waiting":8.73},{"name":"late_night","from":"23:00","to":"07:00","flag":8.15,"per_km":5.85,"per_min":1.25,"per_min_waiting":10.04}],"booking_fee":4.55,"min_fare":20,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":12}}'::jsonb + FROM tripz_tenants t WHERE t.country_pack = 'eg'; + +-- comfort +UPDATE tripz_tariffs SET active = false + WHERE city = 'default' AND service_class = 'comfort' AND active = true + AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'eg'); +INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition) +SELECT uuid_generate_v4(), t.id, 'default', 'comfort', + COALESCE((SELECT MAX(version) FROM tripz_tariffs x + WHERE x.tenant_id = t.id AND x.city = 'default' + AND x.service_class = 'comfort'), 0) + 1, + true, '{"currency":"EGP","timezone":"Africa/Cairo","mode":"time_and_distance","rounding":{"increment":1,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":11.08,"per_km":7.95,"per_min":1.7,"per_min_waiting":10.91},{"name":"normal_day","from":"09:30","to":"16:00","flag":8.86,"per_km":6.36,"per_min":1.36,"per_min_waiting":8.73},{"name":"evening_peak","from":"16:00","to":"19:30","flag":11.08,"per_km":7.95,"per_min":1.7,"per_min_waiting":10.91},{"name":"normal_evening","from":"19:30","to":"23:00","flag":8.86,"per_km":6.36,"per_min":1.36,"per_min_waiting":8.73},{"name":"late_night","from":"23:00","to":"07:00","flag":10.19,"per_km":7.32,"per_min":1.57,"per_min_waiting":10.04}],"booking_fee":4.55,"min_fare":20,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":12}}'::jsonb + FROM tripz_tenants t WHERE t.country_pack = 'eg'; + +-- lady +UPDATE tripz_tariffs SET active = false + WHERE city = 'default' AND service_class = 'lady' AND active = true + AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'eg'); +INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition) +SELECT uuid_generate_v4(), t.id, 'default', 'lady', + COALESCE((SELECT MAX(version) FROM tripz_tariffs x + WHERE x.tenant_id = t.id AND x.city = 'default' + AND x.service_class = 'lady'), 0) + 1, + true, '{"currency":"EGP","timezone":"Africa/Cairo","mode":"time_and_distance","rounding":{"increment":1,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":9.75,"per_km":7,"per_min":1.5,"per_min_waiting":10.91},{"name":"normal_day","from":"09:30","to":"16:00","flag":7.8,"per_km":5.6,"per_min":1.2,"per_min_waiting":8.73},{"name":"evening_peak","from":"16:00","to":"19:30","flag":9.75,"per_km":7,"per_min":1.5,"per_min_waiting":10.91},{"name":"normal_evening","from":"19:30","to":"23:00","flag":7.8,"per_km":5.6,"per_min":1.2,"per_min_waiting":8.73},{"name":"late_night","from":"23:00","to":"07:00","flag":8.97,"per_km":6.44,"per_min":1.38,"per_min_waiting":10.04}],"booking_fee":4.55,"min_fare":20,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":12}}'::jsonb + FROM tripz_tenants t WHERE t.country_pack = 'eg'; + +-- electric +UPDATE tripz_tariffs SET active = false + WHERE city = 'default' AND service_class = 'electric' AND active = true + AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'eg'); +INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition) +SELECT uuid_generate_v4(), t.id, 'default', 'electric', + COALESCE((SELECT MAX(version) FROM tripz_tariffs x + WHERE x.tenant_id = t.id AND x.city = 'default' + AND x.service_class = 'electric'), 0) + 1, + true, '{"currency":"EGP","timezone":"Africa/Cairo","mode":"time_and_distance","rounding":{"increment":1,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":9.31,"per_km":6.68,"per_min":1.43,"per_min_waiting":10.91},{"name":"normal_day","from":"09:30","to":"16:00","flag":7.45,"per_km":5.35,"per_min":1.15,"per_min_waiting":8.73},{"name":"evening_peak","from":"16:00","to":"19:30","flag":9.31,"per_km":6.68,"per_min":1.43,"per_min_waiting":10.91},{"name":"normal_evening","from":"19:30","to":"23:00","flag":7.45,"per_km":5.35,"per_min":1.15,"per_min_waiting":8.73},{"name":"late_night","from":"23:00","to":"07:00","flag":8.56,"per_km":6.15,"per_min":1.32,"per_min_waiting":10.04}],"booking_fee":4.55,"min_fare":20,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":12}}'::jsonb + FROM tripz_tenants t WHERE t.country_pack = 'eg'; + +-- van +UPDATE tripz_tariffs SET active = false + WHERE city = 'default' AND service_class = 'van' AND active = true + AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'eg'); +INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition) +SELECT uuid_generate_v4(), t.id, 'default', 'van', + COALESCE((SELECT MAX(version) FROM tripz_tariffs x + WHERE x.tenant_id = t.id AND x.city = 'default' + AND x.service_class = 'van'), 0) + 1, + true, '{"currency":"EGP","timezone":"Africa/Cairo","mode":"time_and_distance","rounding":{"increment":1,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":12.85,"per_km":9.23,"per_min":1.98,"per_min_waiting":10.91},{"name":"normal_day","from":"09:30","to":"16:00","flag":10.28,"per_km":7.38,"per_min":1.58,"per_min_waiting":8.73},{"name":"evening_peak","from":"16:00","to":"19:30","flag":12.85,"per_km":9.23,"per_min":1.98,"per_min_waiting":10.91},{"name":"normal_evening","from":"19:30","to":"23:00","flag":10.28,"per_km":7.38,"per_min":1.58,"per_min_waiting":8.73},{"name":"late_night","from":"23:00","to":"07:00","flag":11.82,"per_km":8.49,"per_min":1.82,"per_min_waiting":10.04}],"booking_fee":4.55,"min_fare":20,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":12}}'::jsonb + FROM tripz_tenants t WHERE t.country_pack = 'eg'; + +-- delivery +UPDATE tripz_tariffs SET active = false + WHERE city = 'default' AND service_class = 'delivery' AND active = true + AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'eg'); +INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition) +SELECT uuid_generate_v4(), t.id, 'default', 'delivery', + COALESCE((SELECT MAX(version) FROM tripz_tariffs x + WHERE x.tenant_id = t.id AND x.city = 'default' + AND x.service_class = 'delivery'), 0) + 1, + true, '{"currency":"EGP","timezone":"Africa/Cairo","mode":"time_and_distance","rounding":{"increment":1,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":7.98,"per_km":5.73,"per_min":1.23,"per_min_waiting":10.91},{"name":"normal_day","from":"09:30","to":"16:00","flag":6.38,"per_km":4.58,"per_min":0.98,"per_min_waiting":8.73},{"name":"evening_peak","from":"16:00","to":"19:30","flag":7.98,"per_km":5.73,"per_min":1.23,"per_min_waiting":10.91},{"name":"normal_evening","from":"19:30","to":"23:00","flag":6.38,"per_km":4.58,"per_min":0.98,"per_min_waiting":8.73},{"name":"late_night","from":"23:00","to":"07:00","flag":7.34,"per_km":5.27,"per_min":1.13,"per_min_waiting":10.04}],"booking_fee":4.55,"min_fare":20,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":12}}'::jsonb + FROM tripz_tenants t WHERE t.country_pack = 'eg'; + +-- vip +UPDATE tripz_tariffs SET active = false + WHERE city = 'default' AND service_class = 'vip' AND active = true + AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'eg'); +INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition) +SELECT uuid_generate_v4(), t.id, 'default', 'vip', + COALESCE((SELECT MAX(version) FROM tripz_tariffs x + WHERE x.tenant_id = t.id AND x.city = 'default' + AND x.service_class = 'vip'), 0) + 1, + true, '{"currency":"EGP","timezone":"Africa/Cairo","mode":"time_and_distance","rounding":{"increment":1,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":15.51,"per_km":11.14,"per_min":2.39,"per_min_waiting":10.91},{"name":"normal_day","from":"09:30","to":"16:00","flag":12.41,"per_km":8.91,"per_min":1.91,"per_min_waiting":8.73},{"name":"evening_peak","from":"16:00","to":"19:30","flag":15.51,"per_km":11.14,"per_min":2.39,"per_min_waiting":10.91},{"name":"normal_evening","from":"19:30","to":"23:00","flag":12.41,"per_km":8.91,"per_min":1.91,"per_min_waiting":8.73},{"name":"late_night","from":"23:00","to":"07:00","flag":14.27,"per_km":10.25,"per_min":2.2,"per_min_waiting":10.04}],"booking_fee":4.55,"min_fare":20,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":12}}'::jsonb + FROM tripz_tenants t WHERE t.country_pack = 'eg'; + +-- fixed +UPDATE tripz_tariffs SET active = false + WHERE city = 'default' AND service_class = 'fixed' AND active = true + AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'eg'); +INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition) +SELECT uuid_generate_v4(), t.id, 'default', 'fixed', + COALESCE((SELECT MAX(version) FROM tripz_tariffs x + WHERE x.tenant_id = t.id AND x.city = 'default' + AND x.service_class = 'fixed'), 0) + 1, + true, '{"currency":"EGP","timezone":"Africa/Cairo","mode":"fixed_quote","rounding":{"increment":1,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":8.86,"per_km":6.36,"per_min":1.36,"per_min_waiting":10.91},{"name":"normal_day","from":"09:30","to":"16:00","flag":7.09,"per_km":5.09,"per_min":1.09,"per_min_waiting":8.73},{"name":"evening_peak","from":"16:00","to":"19:30","flag":8.86,"per_km":6.36,"per_min":1.36,"per_min_waiting":10.91},{"name":"normal_evening","from":"19:30","to":"23:00","flag":7.09,"per_km":5.09,"per_min":1.09,"per_min_waiting":8.73},{"name":"late_night","from":"23:00","to":"07:00","flag":8.15,"per_km":5.85,"per_min":1.25,"per_min_waiting":10.04}],"booking_fee":0,"min_fare":0,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":12},"fixed_fare":45}'::jsonb + FROM tripz_tenants t WHERE t.country_pack = 'eg'; + +-- saver +UPDATE tripz_tariffs SET active = false + WHERE city = 'default' AND service_class = 'saver' AND active = true + AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'eg'); +INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition) +SELECT uuid_generate_v4(), t.id, 'default', 'saver', + COALESCE((SELECT MAX(version) FROM tripz_tariffs x + WHERE x.tenant_id = t.id AND x.city = 'default' + AND x.service_class = 'saver'), 0) + 1, + true, '{"currency":"EGP","timezone":"Africa/Cairo","mode":"time_and_distance","rounding":{"increment":1,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":7.53,"per_km":5.41,"per_min":1.16,"per_min_waiting":10.91},{"name":"normal_day","from":"09:30","to":"16:00","flag":6.03,"per_km":4.33,"per_min":0.93,"per_min_waiting":8.73},{"name":"evening_peak","from":"16:00","to":"19:30","flag":7.53,"per_km":5.41,"per_min":1.16,"per_min_waiting":10.91},{"name":"normal_evening","from":"19:30","to":"23:00","flag":6.03,"per_km":4.33,"per_min":0.93,"per_min_waiting":8.73},{"name":"late_night","from":"23:00","to":"07:00","flag":6.93,"per_km":4.98,"per_min":1.07,"per_min_waiting":10.04}],"booking_fee":4.55,"min_fare":20,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":12}}'::jsonb + FROM tripz_tenants t WHERE t.country_pack = 'eg'; + +-- ============ SY (SYP) ============ +-- economy +UPDATE tripz_tariffs SET active = false + WHERE city = 'default' AND service_class = 'economy' AND active = true + AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'sy'); +INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition) +SELECT uuid_generate_v4(), t.id, 'default', 'economy', + COALESCE((SELECT MAX(version) FROM tripz_tariffs x + WHERE x.tenant_id = t.id AND x.city = 'default' + AND x.service_class = 'economy'), 0) + 1, + true, '{"currency":"SYP","timezone":"Asia/Damascus","mode":"time_and_distance","rounding":{"increment":5,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":66,"per_km":48,"per_min":10,"per_min_waiting":82},{"name":"normal_day","from":"09:30","to":"16:00","flag":53,"per_km":38,"per_min":8,"per_min_waiting":65},{"name":"evening_peak","from":"16:00","to":"19:30","flag":66,"per_km":48,"per_min":10,"per_min_waiting":82},{"name":"normal_evening","from":"19:30","to":"23:00","flag":53,"per_km":38,"per_min":8,"per_min_waiting":65},{"name":"late_night","from":"23:00","to":"07:00","flag":61,"per_km":44,"per_min":9,"per_min_waiting":75}],"booking_fee":34,"min_fare":150,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":11}}'::jsonb + FROM tripz_tenants t WHERE t.country_pack = 'sy'; + +-- comfort +UPDATE tripz_tariffs SET active = false + WHERE city = 'default' AND service_class = 'comfort' AND active = true + AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'sy'); +INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition) +SELECT uuid_generate_v4(), t.id, 'default', 'comfort', + COALESCE((SELECT MAX(version) FROM tripz_tariffs x + WHERE x.tenant_id = t.id AND x.city = 'default' + AND x.service_class = 'comfort'), 0) + 1, + true, '{"currency":"SYP","timezone":"Asia/Damascus","mode":"time_and_distance","rounding":{"increment":5,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":83,"per_km":60,"per_min":13,"per_min_waiting":82},{"name":"normal_day","from":"09:30","to":"16:00","flag":66,"per_km":48,"per_min":10,"per_min_waiting":65},{"name":"evening_peak","from":"16:00","to":"19:30","flag":83,"per_km":60,"per_min":13,"per_min_waiting":82},{"name":"normal_evening","from":"19:30","to":"23:00","flag":66,"per_km":48,"per_min":10,"per_min_waiting":65},{"name":"late_night","from":"23:00","to":"07:00","flag":76,"per_km":55,"per_min":12,"per_min_waiting":75}],"booking_fee":34,"min_fare":150,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":11}}'::jsonb + FROM tripz_tenants t WHERE t.country_pack = 'sy'; + +-- lady +UPDATE tripz_tariffs SET active = false + WHERE city = 'default' AND service_class = 'lady' AND active = true + AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'sy'); +INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition) +SELECT uuid_generate_v4(), t.id, 'default', 'lady', + COALESCE((SELECT MAX(version) FROM tripz_tariffs x + WHERE x.tenant_id = t.id AND x.city = 'default' + AND x.service_class = 'lady'), 0) + 1, + true, '{"currency":"SYP","timezone":"Asia/Damascus","mode":"time_and_distance","rounding":{"increment":5,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":73,"per_km":53,"per_min":11,"per_min_waiting":82},{"name":"normal_day","from":"09:30","to":"16:00","flag":59,"per_km":42,"per_min":9,"per_min_waiting":65},{"name":"evening_peak","from":"16:00","to":"19:30","flag":73,"per_km":53,"per_min":11,"per_min_waiting":82},{"name":"normal_evening","from":"19:30","to":"23:00","flag":59,"per_km":42,"per_min":9,"per_min_waiting":65},{"name":"late_night","from":"23:00","to":"07:00","flag":67,"per_km":48,"per_min":10,"per_min_waiting":75}],"booking_fee":34,"min_fare":150,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":11}}'::jsonb + FROM tripz_tenants t WHERE t.country_pack = 'sy'; + +-- electric +UPDATE tripz_tariffs SET active = false + WHERE city = 'default' AND service_class = 'electric' AND active = true + AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'sy'); +INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition) +SELECT uuid_generate_v4(), t.id, 'default', 'electric', + COALESCE((SELECT MAX(version) FROM tripz_tariffs x + WHERE x.tenant_id = t.id AND x.city = 'default' + AND x.service_class = 'electric'), 0) + 1, + true, '{"currency":"SYP","timezone":"Asia/Damascus","mode":"time_and_distance","rounding":{"increment":5,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":70,"per_km":50,"per_min":11,"per_min_waiting":82},{"name":"normal_day","from":"09:30","to":"16:00","flag":56,"per_km":40,"per_min":9,"per_min_waiting":65},{"name":"evening_peak","from":"16:00","to":"19:30","flag":70,"per_km":50,"per_min":11,"per_min_waiting":82},{"name":"normal_evening","from":"19:30","to":"23:00","flag":56,"per_km":40,"per_min":9,"per_min_waiting":65},{"name":"late_night","from":"23:00","to":"07:00","flag":64,"per_km":46,"per_min":10,"per_min_waiting":75}],"booking_fee":34,"min_fare":150,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":11}}'::jsonb + FROM tripz_tenants t WHERE t.country_pack = 'sy'; + +-- van +UPDATE tripz_tariffs SET active = false + WHERE city = 'default' AND service_class = 'van' AND active = true + AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'sy'); +INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition) +SELECT uuid_generate_v4(), t.id, 'default', 'van', + COALESCE((SELECT MAX(version) FROM tripz_tariffs x + WHERE x.tenant_id = t.id AND x.city = 'default' + AND x.service_class = 'van'), 0) + 1, + true, '{"currency":"SYP","timezone":"Asia/Damascus","mode":"time_and_distance","rounding":{"increment":5,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":96,"per_km":69,"per_min":15,"per_min_waiting":82},{"name":"normal_day","from":"09:30","to":"16:00","flag":77,"per_km":55,"per_min":12,"per_min_waiting":65},{"name":"evening_peak","from":"16:00","to":"19:30","flag":96,"per_km":69,"per_min":15,"per_min_waiting":82},{"name":"normal_evening","from":"19:30","to":"23:00","flag":77,"per_km":55,"per_min":12,"per_min_waiting":65},{"name":"late_night","from":"23:00","to":"07:00","flag":89,"per_km":64,"per_min":14,"per_min_waiting":75}],"booking_fee":34,"min_fare":150,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":11}}'::jsonb + FROM tripz_tenants t WHERE t.country_pack = 'sy'; + +-- delivery +UPDATE tripz_tariffs SET active = false + WHERE city = 'default' AND service_class = 'delivery' AND active = true + AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'sy'); +INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition) +SELECT uuid_generate_v4(), t.id, 'default', 'delivery', + COALESCE((SELECT MAX(version) FROM tripz_tariffs x + WHERE x.tenant_id = t.id AND x.city = 'default' + AND x.service_class = 'delivery'), 0) + 1, + true, '{"currency":"SYP","timezone":"Asia/Damascus","mode":"time_and_distance","rounding":{"increment":5,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":60,"per_km":43,"per_min":9,"per_min_waiting":82},{"name":"normal_day","from":"09:30","to":"16:00","flag":48,"per_km":34,"per_min":7,"per_min_waiting":65},{"name":"evening_peak","from":"16:00","to":"19:30","flag":60,"per_km":43,"per_min":9,"per_min_waiting":82},{"name":"normal_evening","from":"19:30","to":"23:00","flag":48,"per_km":34,"per_min":7,"per_min_waiting":65},{"name":"late_night","from":"23:00","to":"07:00","flag":55,"per_km":40,"per_min":8,"per_min_waiting":75}],"booking_fee":34,"min_fare":150,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":11}}'::jsonb + FROM tripz_tenants t WHERE t.country_pack = 'sy'; + +-- vip +UPDATE tripz_tariffs SET active = false + WHERE city = 'default' AND service_class = 'vip' AND active = true + AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'sy'); +INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition) +SELECT uuid_generate_v4(), t.id, 'default', 'vip', + COALESCE((SELECT MAX(version) FROM tripz_tariffs x + WHERE x.tenant_id = t.id AND x.city = 'default' + AND x.service_class = 'vip'), 0) + 1, + true, '{"currency":"SYP","timezone":"Asia/Damascus","mode":"time_and_distance","rounding":{"increment":5,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":116,"per_km":84,"per_min":18,"per_min_waiting":82},{"name":"normal_day","from":"09:30","to":"16:00","flag":93,"per_km":67,"per_min":14,"per_min_waiting":65},{"name":"evening_peak","from":"16:00","to":"19:30","flag":116,"per_km":84,"per_min":18,"per_min_waiting":82},{"name":"normal_evening","from":"19:30","to":"23:00","flag":93,"per_km":67,"per_min":14,"per_min_waiting":65},{"name":"late_night","from":"23:00","to":"07:00","flag":107,"per_km":77,"per_min":16,"per_min_waiting":75}],"booking_fee":34,"min_fare":150,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":11}}'::jsonb + FROM tripz_tenants t WHERE t.country_pack = 'sy'; + +-- fixed +UPDATE tripz_tariffs SET active = false + WHERE city = 'default' AND service_class = 'fixed' AND active = true + AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'sy'); +INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition) +SELECT uuid_generate_v4(), t.id, 'default', 'fixed', + COALESCE((SELECT MAX(version) FROM tripz_tariffs x + WHERE x.tenant_id = t.id AND x.city = 'default' + AND x.service_class = 'fixed'), 0) + 1, + true, '{"currency":"SYP","timezone":"Asia/Damascus","mode":"fixed_quote","rounding":{"increment":5,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":66,"per_km":48,"per_min":10,"per_min_waiting":82},{"name":"normal_day","from":"09:30","to":"16:00","flag":53,"per_km":38,"per_min":8,"per_min_waiting":65},{"name":"evening_peak","from":"16:00","to":"19:30","flag":66,"per_km":48,"per_min":10,"per_min_waiting":82},{"name":"normal_evening","from":"19:30","to":"23:00","flag":53,"per_km":38,"per_min":8,"per_min_waiting":65},{"name":"late_night","from":"23:00","to":"07:00","flag":61,"per_km":44,"per_min":9,"per_min_waiting":75}],"booking_fee":0,"min_fare":0,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":11},"fixed_fare":350}'::jsonb + FROM tripz_tenants t WHERE t.country_pack = 'sy'; + +-- saver +UPDATE tripz_tariffs SET active = false + WHERE city = 'default' AND service_class = 'saver' AND active = true + AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'sy'); +INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition) +SELECT uuid_generate_v4(), t.id, 'default', 'saver', + COALESCE((SELECT MAX(version) FROM tripz_tariffs x + WHERE x.tenant_id = t.id AND x.city = 'default' + AND x.service_class = 'saver'), 0) + 1, + true, '{"currency":"SYP","timezone":"Asia/Damascus","mode":"time_and_distance","rounding":{"increment":5,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":57,"per_km":41,"per_min":9,"per_min_waiting":82},{"name":"normal_day","from":"09:30","to":"16:00","flag":45,"per_km":32,"per_min":7,"per_min_waiting":65},{"name":"evening_peak","from":"16:00","to":"19:30","flag":57,"per_km":41,"per_min":9,"per_min_waiting":82},{"name":"normal_evening","from":"19:30","to":"23:00","flag":45,"per_km":32,"per_min":7,"per_min_waiting":65},{"name":"late_night","from":"23:00","to":"07:00","flag":52,"per_km":37,"per_min":8,"per_min_waiting":75}],"booking_fee":34,"min_fare":150,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":11}}'::jsonb + FROM tripz_tenants t WHERE t.country_pack = 'sy'; + +COMMIT; diff --git a/backend/src/modules/auth/auth.controller.ts b/backend/src/modules/auth/auth.controller.ts index b97a43b..3707c67 100644 --- a/backend/src/modules/auth/auth.controller.ts +++ b/backend/src/modules/auth/auth.controller.ts @@ -35,9 +35,11 @@ export class AuthController { @Headers('x-device-id') deviceId: string, @Body('phone') phone: string, @Body('code') code: string, + // كود دعوة اختياري — يُسجَّل للمستخدم الجديد وحده (المجموعة L). + @Body('referral_code') referralCode?: string, ) { if (!tenantId) throw new UnauthorizedException('Tenant ID (x-tenant-id) is required'); - return this.authService.verifyOtp(tenantId, phone, code, deviceId); + return this.authService.verifyOtp(tenantId, phone, code, deviceId, referralCode); } @Post('refresh') diff --git a/backend/src/modules/auth/auth.module.ts b/backend/src/modules/auth/auth.module.ts index 18ef676..d1541d7 100644 --- a/backend/src/modules/auth/auth.module.ts +++ b/backend/src/modules/auth/auth.module.ts @@ -7,11 +7,13 @@ import { AuthController } from './auth.controller'; import { UsersModule } from '../users/users.module'; import { TenantsModule } from '../tenants/tenants.module'; import { JwtStrategy } from './strategies/jwt.strategy'; +import { RewardsModule } from '../rewards/rewards.module'; @Module({ imports: [ UsersModule, TenantsModule, + RewardsModule, // تسجيل كود الدعوة عند إنشاء مستخدم جديد (المجموعة L) PassportModule, JwtModule.registerAsync({ imports: [ConfigModule], diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index bce9434..c0d802d 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -17,6 +17,7 @@ import { Tenant } from '../../database/entities/tenant.entity'; import { OtpDispatcher } from '../../integrations/otp/otp-dispatcher.service'; import { PhoneService } from '../../common/phone/phone.service'; import { DeviceService } from '../../common/device/device.service'; +import { ReferralsService } from '../rewards/referrals.service'; @Injectable() export class AuthService { @@ -31,6 +32,7 @@ export class AuthService { private readonly signing: SigningService, private readonly phones: PhoneService, private readonly device: DeviceService, + private readonly referrals: ReferralsService, @Inject(REDIS) private readonly redis: Redis, ) {} @@ -102,7 +104,13 @@ export class AuthService { return { success: true, message: 'OTP sent' }; } - async verifyOtp(tenantSlug: string, phone: string, code: string, deviceId?: string) { + async verifyOtp( + tenantSlug: string, + phone: string, + code: string, + deviceId?: string, + referralCode?: string, + ) { const tenant = await this.resolveTenant(tenantSlug); // نفس التطبيع بالضبط — وإلا فشل التحقق لمجرد أن المستخدم كتب الرقم // بصيغة مختلفة قليلاً عن مرة الإرسال (مثال المالك: "01" مقابل "1"). @@ -134,6 +142,16 @@ export class AuthService { let user = await this.usersService.findByPhone(tenant.id, canonical); if (!user) { user = await this.usersService.create(tenant.id, canonical); + // كود الدعوة يُسجَّل **للمستخدم الجديد وحده** (المجموعة L): قبوله عند + // كل دخول كان يعني أن مستخدماً قديماً يُحال بعد سنة من استعماله. + // لا يرمي عند كود خاطئ — فشل الإحالة يجب ألّا يمنع أحداً من الدخول. + if (referralCode) { + try { + await this.referrals.register(tenant.id, user.id, 'rider', referralCode); + } catch (e: any) { + this.logger.warn(`referral registration failed for ${user.id}: ${e?.message}`); + } + } } return this.issueTokens(user, tenant.id, deviceId); } diff --git a/backend/src/modules/credit/driver-credit.service.ts b/backend/src/modules/credit/driver-credit.service.ts index 365ea78..f1bad2a 100644 --- a/backend/src/modules/credit/driver-credit.service.ts +++ b/backend/src/modules/credit/driver-credit.service.ts @@ -44,6 +44,45 @@ export class DriverCreditService { return this.apply(tenantId, driverId, amount, 'promo_bonus', { ref }); } + /** + * مكافأة إحالة للسائق (المجموعة L) — **مرة واحدة لكل إحالة**. + * + * `ref` هو معرّف الإحالة، ويحرسه فهرس فريد جزئي على القاعدة + * (`IDX_tripz_credit_txns_referral_once`) بنفس منطق J6: الماسح والمسار + * الحدثي قد يصرفان معاً، والقيد وحده هو ما يمنع الصرف مرتين. + * + * يعيد `false` إذا كانت مصروفة سلفاً — فيعرف المنادي ألّا يعلّمها مدفوعة + * بناءً على نجاحٍ لم يحدث. + */ + async grantReferralBonus( + tenantId: string, + driverId: string, + amount: number, + referralId: string, + currency = 'JOD', + ): Promise { + if (!(amount > 0)) return false; + + // حارسان مثل J6: فحص تطبيقي للحالة الشائعة (الماسح يعيد المحاولة)، + // وفهرس فريد جزئي على القاعدة للسباق الحقيقي. الفحص وحده يسقط تحت + // التزامن، والفهرس وحده يجعل الحالة الشائعة استثناءً — فكلاهما. + const already = await this.txns.findOne({ + where: { tenant_id: tenantId, type: 'referral_bonus', ref: referralId }, + }); + if (already) return false; + + try { + await this.apply(tenantId, driverId, amount, 'referral_bonus', { ref: referralId }, currency); + return true; + } catch (e: any) { + if (e?.code === '23505' || e?.driverError?.code === '23505') { + this.logger.debug(`referral bonus already granted for ${referralId}`); + return false; + } + throw e; + } + } + /** * خصم عمولة رحلة (docs/18 §5.2). * **لا يفشل أبداً** — ولو صار الرصيد سالباً. الرحلة تمّت فالعمولة استُحقّت؛ diff --git a/backend/src/modules/credit/entities/credit-txn.entity.ts b/backend/src/modules/credit/entities/credit-txn.entity.ts index 5cad323..73586b6 100644 --- a/backend/src/modules/credit/entities/credit-txn.entity.ts +++ b/backend/src/modules/credit/entities/credit-txn.entity.ts @@ -15,6 +15,7 @@ export type CreditTxnType = | 'topup' // شحن مدفوع — إيراد | 'signup_bonus' // مكافأة تسجيل — تكلفة تجنيد | 'promo_bonus' // حافز باقة («اشحن 50 خذ 55») — تكلفة تسويق + | 'referral_bonus'// مكافأة إحالة — تكلفة تجنيد، منفصلة عن حافز الباقة | 'commission' // خصم عمولة رحلة | 'adjustment'; // تسوية يدوية من الأدمن diff --git a/backend/src/modules/rewards/coupon-cap.spec.ts b/backend/src/modules/rewards/coupon-cap.spec.ts new file mode 100644 index 0000000..7cb52a7 --- /dev/null +++ b/backend/src/modules/rewards/coupon-cap.spec.ts @@ -0,0 +1,104 @@ +import { computeDiscount } from './coupons.service'; +import { resolveRewardsPolicy, DEFAULT_MAX_DISCOUNT } from './rewards-policy'; + +const coupon = ( + discount_type: 'percent' | 'flat', + discount_value: number, + max_discount: number | null = null, +) => ({ discount_type, discount_value, max_discount }) as any; + +describe('computeDiscount — سقف الكوبون (المجموعة L)', () => { + it('نسبة مئوية بلا سقف تُطبَّق كما هي حين لا تبلغ الحدّ', () => { + // 10% من 100 جنيه = 10، وسقف مصر 25 — فلا قصّ. + const r = computeDiscount(coupon('percent', 10), { maxDiscount: 25 }, 100); + expect(r.discount).toBe(10); + expect(r.capped_by).toBeUndefined(); + }); + + it('**سقف السياسة يقصّ النسبة المئوية** — قرار المالك: 25 جنيهاً حدّاً أعلى', () => { + // 50% من 300 جنيه = 150 — تُقصّ إلى 25. هذا هو البند المطلوب حرفياً: + // كوبون نسبته كبيرة على أجرة كبيرة يجب ألّا يأكل الأجرة. + const r = computeDiscount(coupon('percent', 50), { maxDiscount: 25 }, 300); + expect(r.discount).toBe(25); + expect(r.capped_by).toBe('policy'); + }); + + it('سقف السياسة يقصّ الخصم الثابت أيضاً — لا مهرب عبر نوع الخصم', () => { + const r = computeDiscount(coupon('flat', 200), { maxDiscount: 25 }, 300); + expect(r.discount).toBe(25); + expect(r.capped_by).toBe('policy'); + }); + + it('سقف الكوبون الأدنى يتقدّم على سقف السياسة', () => { + const r = computeDiscount(coupon('percent', 50, 10), { maxDiscount: 25 }, 300); + expect(r.discount).toBe(10); + expect(r.capped_by).toBe('coupon'); + }); + + it('**سقف كوبون أعلى من السياسة لا يرفع الحدّ** — السياسة حدّ مطلق', () => { + // الثغرة الجوهرية: لو كان سقف الكوبون هو الحاكم، لَفتح أدمنٌ (أو إعداد + // مكسور) خصماً بلا حدّ بمجرّد كتابة رقم كبير في حقل الكوبون. + const r = computeDiscount(coupon('percent', 50, 999), { maxDiscount: 25 }, 300); + expect(r.discount).toBe(25); + expect(r.capped_by).toBe('policy'); + }); + + it('الخصم لا يتجاوز الأجرة — لا أجرة سالبة ولا دفعٌ للراكب', () => { + const r = computeDiscount(coupon('flat', 25), { maxDiscount: 25 }, 8); + expect(r.discount).toBe(8); + expect(r.capped_by).toBe('fare'); + }); + + it('سقف صفر = لا خصم بهذه العملة (قرار مشروع لا قيمة مفقودة)', () => { + const r = computeDiscount(coupon('percent', 50), { maxDiscount: 0 }, 300); + expect(r.discount).toBe(0); + }); +}); + +describe('resolveRewardsPolicy — سقف كل عملة', () => { + it('الافتراضات: 25 جنيهاً في مصر ومقابلها في الأردن وسوريا', () => { + expect(resolveRewardsPolicy(null, 'EGP').maxDiscount).toBe(25); + expect(resolveRewardsPolicy(null, 'SYP').maxDiscount).toBe(190); + expect(resolveRewardsPolicy(null, 'JOD').maxDiscount).toBe(1.375); + expect(DEFAULT_MAX_DISCOUNT.EGP).toBe(25); + }); + + it('**السقوف متناسبة مع الحدود الدنيا** — لا نسبة داخلية قديمة', () => { + // مرساة المالك: 1.10 دينار = 20 جنيهاً = 150 ليرة. السقف يجب أن يحافظ + // على النسبة نفسها، وإلا صار العرض في بلدٍ سُدس ما هو عليه في آخر. + const jo = resolveRewardsPolicy(null, 'JOD').maxDiscount / 1.1; + const eg = resolveRewardsPolicy(null, 'EGP').maxDiscount / 20; + const sy = resolveRewardsPolicy(null, 'SYP').maxDiscount / 150; + expect(eg).toBeCloseTo(jo, 1); + expect(sy).toBeCloseTo(jo, 1); + }); + + it('إعداد المستأجر يتقدّم على الافتراض', () => { + const s = { rewards: { max_discount: { EGP: 40 } } }; + expect(resolveRewardsPolicy(s, 'EGP').maxDiscount).toBe(40); + }); + + it('سقف صفر إعدادٌ صالح — لا يعود إلى الافتراض (فخّ الصفر الصادق)', () => { + const s = { rewards: { max_discount: { EGP: 0 } } }; + expect(resolveRewardsPolicy(s, 'EGP').maxDiscount).toBe(0); + }); + + it('إعداد مكسور يسقط للافتراض — لا يفتح خصماً بلا سقف', () => { + expect(resolveRewardsPolicy({ rewards: { max_discount: { EGP: 'كثير' } } }, 'EGP').maxDiscount) + .toBe(25); + expect(resolveRewardsPolicy({ rewards: { max_discount: { EGP: -5 } } }, 'EGP').maxDiscount) + .toBe(25); + }); + + it('عملة بلا سياسة = سقف صفر لا سقف مفتوح', () => { + // الافتراض هو المنع: عملة أُضيفت ولم تُضبط سياستها يجب ألّا تعني + // «اخصم بلا حدّ» — نفس منطق القائمة البيضاء في K2. + expect(resolveRewardsPolicy(null, 'USD').maxDiscount).toBe(0); + }); + + it('شرط التأهيل لا ينزل تحت رحلة واحدة ولو ضبطه الأدمن صفراً', () => { + // 0 يعني الصرف عند التسجيل — وهو باب الاحتيال الذي بُني الحقل لإغلاقه. + const s = { rewards: { qualifying_trips: 0 } }; + expect(resolveRewardsPolicy(s, 'JOD').qualifyingTrips).toBe(1); + }); +}); diff --git a/backend/src/modules/rewards/coupons.service.ts b/backend/src/modules/rewards/coupons.service.ts new file mode 100644 index 0000000..4455f76 --- /dev/null +++ b/backend/src/modules/rewards/coupons.service.ts @@ -0,0 +1,360 @@ +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Coupon } from './entities/coupon.entity'; +import { CouponRedemption } from './entities/coupon-redemption.entity'; +import { RewardsPolicy, resolveRewardsPolicy } from './rewards-policy'; +import { TenantsService } from '../tenants/tenants.service'; + +/** سبب رفض الكوبون — يُترجَم في الواجهة، فلا نص عربي هنا. */ +export type CouponRejection = + | 'not_found' + | 'disabled' + | 'not_started' + | 'expired' + | 'currency_mismatch' + | 'fare_too_low' + | 'exhausted' + | 'user_limit' + | 'not_yours'; + +export interface CouponEvaluation { + ok: boolean; + reason?: CouponRejection; + coupon?: Coupon; + /** الخصم بعد تطبيق السقف — هذا ما يُخصم فعلاً، لا القيمة الاسمية. */ + discount: number; + /** الحدّ الذي قصّ الخصم، إن قُصّ — لتفسير «ليش خصمي أقل من الكوبون؟». */ + capped_by?: 'coupon' | 'policy' | 'fare'; +} + +/** + * يحسب خصم كوبون على أجرة — **دالة صافية** لا قاعدة ولا وقت، لتُختبر وحدها. + * + * ترتيب القصّ مقصود: القيمة الاسمية ← سقف الكوبون ← **سقف السياسة** ← + * الأجرة نفسها. سقف السياسة بعد سقف الكوبون لا قبله، فهو حدّ أعلى مطلق + * لا افتراض: كوبون يعلن سقفاً أعلى لا يرفع الحدّ، والأدنى دائماً هو الحاكم. + * وقصّ الأجرة أخيراً يمنع أجرةً سالبة (خصم أكبر من الأجرة = دفعٌ للراكب). + */ +export function computeDiscount( + coupon: Pick, + policy: Pick, + fare: number, +): { discount: number; capped_by?: 'coupon' | 'policy' | 'fare' } { + const value = Number(coupon.discount_value) || 0; + let discount = + coupon.discount_type === 'percent' ? (fare * value) / 100 : value; + let capped_by: 'coupon' | 'policy' | 'fare' | undefined; + + const couponCap = coupon.max_discount == null ? null : Number(coupon.max_discount); + if (couponCap != null && discount > couponCap) { + discount = couponCap; + capped_by = 'coupon'; + } + const policyCap = Number(policy.maxDiscount); + // `> 0` لا `!= null`: سقف صفر يعني «لا خصم بهذه العملة» وهو قرار مشروع، + // بينما تجاهله كان سيفتح الخصم بلا حدّ في عملة لم تُضبط سياستها بعد. + if (Number.isFinite(policyCap) && discount > policyCap) { + discount = policyCap; + capped_by = 'policy'; + } + if (discount > fare) { + discount = fare; + capped_by = 'fare'; + } + if (!(discount > 0)) return { discount: 0, capped_by }; + return { discount: Number(discount.toFixed(3)), capped_by }; +} + +@Injectable() +export class CouponsService { + private readonly logger = new Logger('Coupons'); + + constructor( + @InjectRepository(Coupon) private readonly coupons: Repository, + @InjectRepository(CouponRedemption) + private readonly redemptions: Repository, + private readonly tenants: TenantsService, + ) {} + + /** سياسة المكافآت لعملةٍ ما — عبر كاش المستأجر القائم (نمط `credit.policy`). */ + async policy(tenantId: string, currency = 'JOD'): Promise { + const t = await this.tenants.resolve(tenantId); + return resolveRewardsPolicy(t?.settings, currency); + } + + /** التطبيع: الكود يُملى صوتياً ويُكتب بأي حالة — المطابقة تفشل بدونه. */ + static normalize(code: string): string { + return (code ?? '').trim().toUpperCase(); + } + + /** + * يقيّم كوبوناً بلا أي أثر — للاقتباس («كم سأدفع؟») قبل إنشاء الرحلة. + * لا يستهلك شيئاً: الاستهلاك في `redeem` عند تسوية الأجرة وحدها. + */ + async evaluate( + tenantId: string, + userId: string, + code: string, + fare: number, + currency = 'JOD', + now: Date = new Date(), + ): Promise { + const coupon = await this.coupons.findOne({ + where: { tenant_id: tenantId, code: CouponsService.normalize(code) }, + }); + if (!coupon) return { ok: false, reason: 'not_found', discount: 0 }; + + const reject = (reason: CouponRejection): CouponEvaluation => ({ + ok: false, + reason, + coupon, + discount: 0, + }); + + if (coupon.status !== 'active') return reject('disabled'); + // كوبون مُوجَّه لشخص: لا يستعمله غيره ولو عرف الكود. + if (coupon.user_id && coupon.user_id !== userId) return reject('not_yours'); + if (coupon.valid_from && now < new Date(coupon.valid_from)) return reject('not_started'); + if (coupon.valid_until && now > new Date(coupon.valid_until)) return reject('expired'); + if (coupon.currency !== currency) return reject('currency_mismatch'); + if (coupon.min_fare != null && fare < Number(coupon.min_fare)) return reject('fare_too_low'); + if ( + coupon.max_redemptions != null && + coupon.redeemed_count >= coupon.max_redemptions + ) { + return reject('exhausted'); + } + + const used = await this.redemptions.count({ + where: { tenant_id: tenantId, coupon_id: coupon.id, user_id: userId }, + }); + if (used >= coupon.max_per_user) return reject('user_limit'); + + const policy = await this.policy(tenantId, currency); + const { discount, capped_by } = computeDiscount(coupon, policy, fare); + if (!(discount > 0)) return reject('fare_too_low'); + + return { ok: true, coupon, discount, capped_by }; + } + + /** + * يستهلك الكوبون على رحلة ويكتب الدفتر. يُنادى **مرة واحدة** عند تسوية + * الأجرة، لا عند الاقتباس: الاقتباس يتكرر مع كل تغيير وجهة. + * + * يعيد الخصم المطبَّق فعلاً، أو 0 إذا سقط الكوبون بين الاقتباس والتسوية + * (نفد · انتهى · استُعمل على رحلة أخرى). **لا يرمي في المسار العادي**: + * الرحلة تمّت، ورفض تسويتها لأجل كوبون يعني رحلةً معلّقة بلا أجرة. + */ + async redeem( + tenantId: string, + userId: string, + code: string, + tripId: string, + fare: number, + currency = 'JOD', + ): Promise { + const evaluation = await this.evaluate(tenantId, userId, code, fare, currency); + if (!evaluation.ok || !evaluation.coupon) { + this.logger.warn( + `coupon ${CouponsService.normalize(code)} rejected at settle for trip ${tripId}: ${evaluation.reason}`, + ); + return 0; + } + const coupon = evaluation.coupon; + + // الحجز ذرّي على القاعدة: الشرط داخل الـUPDATE لا قبله، فطلبان متزامنان + // على آخر استعمال متبقٍّ لا ينجحان معاً (نمط I1). + const res = await this.coupons + .createQueryBuilder() + .update(Coupon) + .set({ redeemed_count: () => 'redeemed_count + 1' }) + .where('id = :id AND tenant_id = :tenant AND status = :active', { + id: coupon.id, + tenant: tenantId, + active: 'active', + }) + .andWhere('(max_redemptions IS NULL OR redeemed_count < max_redemptions)') + .execute(); + if (!res.affected) { + this.logger.warn(`coupon ${coupon.code} exhausted mid-settle for trip ${tripId}`); + return 0; + } + + try { + await this.redemptions.insert({ + tenant_id: tenantId, + coupon_id: coupon.id, + user_id: userId, + trip_id: tripId, + discount_amount: evaluation.discount, + fare_before: fare, + currency, + }); + } catch (e: any) { + // الفريد على (tenant, trip) صدّ محاولة خصم ثانية على نفس الرحلة — + // نُرجع الحجز حتى لا يضيع استعمالٌ على لا شيء. + await this.coupons + .createQueryBuilder() + .update(Coupon) + .set({ redeemed_count: () => 'GREATEST(redeemed_count - 1, 0)' }) + .where('id = :id', { id: coupon.id }) + .execute(); + this.logger.warn(`coupon redemption rejected for trip ${tripId}: ${e?.message}`); + return 0; + } + + return evaluation.discount; + } + + /** + * يمنح كوبوناً مُوجَّهاً لمستخدم (مكافأة إحالة أو تعويض دعم). + * القيمة تُقصّ بسقف السياسة **عند المنح أيضاً** لا عند الاستعمال وحده: + * كوبون معروض بقيمة لن تُصرف كاملةً وعدٌ مكسور للمستخدم. + */ + async grant(params: { + tenantId: string; + userId: string; + amount: number; + currency: string; + source: string; + validDays: number; + codePrefix?: string; + /** سبب المنح — يجعل العملية idempotent عبر الفهرس الفريد الجزئي. */ + ref?: string; + }): Promise { + const policy = await this.policy(params.tenantId, params.currency); + const amount = Math.min(Number(params.amount), policy.maxDiscount); + if (!(amount > 0)) throw new BadRequestException('coupon amount must be > 0'); + + // فحص مبكر للحالة الشائعة (إعادة محاولة الماسح) — القيد هو الحارس الفعلي. + if (params.ref) { + const already = await this.coupons.findOne({ + where: { tenant_id: params.tenantId, ref: params.ref }, + }); + if (already) return already; + } + + const validUntil = new Date(); + validUntil.setDate(validUntil.getDate() + params.validDays); + + for (let attempt = 0; attempt < 5; attempt++) { + const code = `${params.codePrefix ?? 'GIFT'}${randomCode(6)}`; + try { + return await this.coupons.save( + this.coupons.create({ + tenant_id: params.tenantId, + code, + user_id: params.userId, + discount_type: 'flat', + discount_value: amount, + max_discount: amount, + currency: params.currency, + valid_until: validUntil, + max_redemptions: 1, + max_per_user: 1, + source: params.source, + ref: params.ref ?? null, + }), + ); + } catch (e: any) { + const dup = e?.code === '23505' || e?.driverError?.code === '23505'; + // التصادم إمّا على الكود العشوائي (نعيد التوليد) أو على `ref` — وهذا + // يعني أن نداءً متزامناً سبقنا للمكافأة نفسها، فلا نمنح ثانية. + if (dup && params.ref) { + const existing = await this.coupons.findOne({ + where: { tenant_id: params.tenantId, ref: params.ref }, + }); + if (existing) return existing; + } + if (attempt === 4) throw e; + } + } + throw new BadRequestException('could not allocate coupon code'); + } + + /** + * كود حملة عام يكتبه أي راكب. لا `user_id` — بخلاف كوبون الإحالة. + * + * `max_redemptions` غير المضبوط يعني حملة مفتوحة، وهو خيار مشروع لكنه + * مكلف: نسجّله تحذيراً حتى لا يمرّ سهواً في إعداد أدمن. + */ + async createCampaign( + tenantId: string, + body: { + code: string; + discount_type?: 'percent' | 'flat'; + discount_value: number; + max_discount?: number; + min_fare?: number; + currency?: string; + valid_until?: string; + max_redemptions?: number; + max_per_user?: number; + }, + ): Promise { + const code = CouponsService.normalize(body.code); + if (!code) throw new BadRequestException('code is required'); + const value = Number(body.discount_value); + if (!Number.isFinite(value) || value < 0) { + throw new BadRequestException('discount_value must be >= 0'); + } + const type = body.discount_type ?? 'percent'; + if (type === 'percent' && value > 100) { + throw new BadRequestException('percent discount cannot exceed 100'); + } + if (body.max_redemptions == null) { + this.logger.warn(`campaign coupon ${code} created with no global redemption limit`); + } + + try { + return await this.coupons.save( + this.coupons.create({ + tenant_id: tenantId, + code, + user_id: null, + discount_type: type, + discount_value: value, + max_discount: body.max_discount ?? null, + min_fare: body.min_fare ?? null, + currency: body.currency ?? 'JOD', + valid_until: body.valid_until ? new Date(body.valid_until) : null, + max_redemptions: body.max_redemptions ?? null, + max_per_user: body.max_per_user ?? 1, + source: 'campaign', + }), + ); + } catch (e: any) { + if (e?.code === '23505' || e?.driverError?.code === '23505') { + throw new BadRequestException(`coupon code ${code} already exists`); + } + throw e; + } + } + + /** كوبونات المستخدم الصالحة الآن — لشاشة «مكافآتي». */ + async listMine(tenantId: string, userId: string): Promise { + const all = await this.coupons.find({ + where: { tenant_id: tenantId, user_id: userId }, + order: { created_at: 'DESC' }, + }); + const now = new Date(); + return all.filter( + (c) => + c.status === 'active' && + (c.max_redemptions == null || c.redeemed_count < c.max_redemptions) && + (!c.valid_until || new Date(c.valid_until) >= now), + ); + } +} + +/** بلا 0/O/1/I/L — الكود يُملى صوتياً وهذه الأحرف تُسمع سواءً. */ +const ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'; + +export function randomCode(len: number): string { + let out = ''; + for (let i = 0; i < len; i++) { + out += ALPHABET[Math.floor(Math.random() * ALPHABET.length)]; + } + return out; +} diff --git a/backend/src/modules/rewards/entities/coupon-redemption.entity.ts b/backend/src/modules/rewards/entities/coupon-redemption.entity.ts new file mode 100644 index 0000000..905354a --- /dev/null +++ b/backend/src/modules/rewards/entities/coupon-redemption.entity.ts @@ -0,0 +1,47 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, +} from 'typeorm'; + +/** + * دفتر استهلاك الكوبونات. الجدول: tripz_coupon_redemptions. append-only. + * + * هو مصدر الحقيقة لكم أُنفق تسويقياً — `coupons.redeemed_count` حارس تزامن + * فقط. الفريد على `(tenant_id, trip_id)` يمنع خصم كوبونين على رحلة واحدة. + */ +@Entity('coupon_redemptions') +@Index(['tenant_id', 'coupon_id']) +@Index(['tenant_id', 'user_id']) +export class CouponRedemption { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + tenant_id: string; + + @Column({ type: 'uuid' }) + coupon_id: string; + + @Column({ type: 'uuid' }) + user_id: string; + + @Column({ type: 'uuid' }) + trip_id: string; + + /** الخصم المطبَّق فعلاً — بعد السقف، لا القيمة الاسمية للكوبون. */ + @Column({ type: 'numeric', precision: 12, scale: 3 }) + discount_amount: number; + + /** الأجرة قبل الخصم — بدونها لا يُقرأ أثر الحملة من الدفتر وحده. */ + @Column({ type: 'numeric', precision: 12, scale: 3, nullable: true }) + fare_before: number | null; + + @Column({ type: 'varchar', default: 'JOD' }) + currency: string; + + @CreateDateColumn() + created_at: Date; +} diff --git a/backend/src/modules/rewards/entities/coupon.entity.ts b/backend/src/modules/rewards/entities/coupon.entity.ts new file mode 100644 index 0000000..aa620d0 --- /dev/null +++ b/backend/src/modules/rewards/entities/coupon.entity.ts @@ -0,0 +1,105 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; + +export type CouponDiscountType = 'percent' | 'flat'; + +/** + * كوبون خصم على أجرة الراكب. الجدول: tripz_coupons. + * + * الخصم يمسّ `price_for_passenger` وحده — `price_for_driver` يبقى كاملاً + * (docs/18 §52). فالكوبون تكلفة تسويق على المنصة، لا اقتطاع من دخل السائق. + * + * الكوبون كيان واحد لحالتين: **كود عام** يكتبه الراكب (`user_id = null`)، + * و**كوبون مُوجَّه** يُمنح لشخص بعينه كمكافأة إحالة (`user_id` مضبوط). + * فصلهما لجدولين كان سيكرّر منطق السقف والصلاحية والاستهلاك حرفياً. + */ +@Entity('coupons') +@Index(['tenant_id', 'code'], { unique: true }) +@Index(['tenant_id', 'user_id']) +export class Coupon { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + tenant_id: string; + + /** يُخزَّن دائماً بحروف كبيرة — المطابقة حساسة لولا التطبيع. */ + @Column({ type: 'varchar' }) + code: string; + + /** مالك الكوبون؛ `null` = كود عام متاح للجميع. */ + @Column({ type: 'uuid', nullable: true }) + user_id: string | null; + + @Column({ type: 'varchar', default: 'percent' }) + discount_type: CouponDiscountType; + + /** نسبة مئوية (0–100) أو مبلغ ثابت بحسب `discount_type`. */ + @Column({ type: 'numeric', precision: 12, scale: 3 }) + discount_value: number; + + /** + * سقف خصم هذا الكوبون. `null` = لا سقف خاص، فيُطبَّق سقف سياسة المستأجر. + * وفي كل الأحوال **لا يتجاوز الخصمُ سقفَ السياسة** ولو كان هذا أعلى: + * السقف الأدنى من الاثنين هو الحاكم (`rewards-policy.ts`). + */ + @Column({ type: 'numeric', precision: 12, scale: 3, nullable: true }) + max_discount: number | null; + + /** لا يُطبَّق الكوبون على أجرة أقل من هذا — يمنع استهلاكه على رحلة رمزية. */ + @Column({ type: 'numeric', precision: 12, scale: 3, nullable: true }) + min_fare: number | null; + + @Column({ type: 'varchar', default: 'JOD' }) + currency: string; + + @Column({ type: 'timestamp', nullable: true }) + valid_from: Date | null; + + @Column({ type: 'timestamp', nullable: true }) + valid_until: Date | null; + + /** أقصى استهلاك إجمالي للكود كله؛ `null` = بلا حدّ. */ + @Column({ type: 'int', nullable: true }) + max_redemptions: number | null; + + /** أقصى استهلاك للراكب الواحد من هذا الكود. */ + @Column({ type: 'int', default: 1 }) + max_per_user: number; + + /** + * عدّاد الاستهلاك — يُزاد بـ`UPDATE … WHERE redeemed < max` الشرطي، فلا + * يتجاوز طلبان متزامنان الحدَّ معاً. المصدر النهائي للحقيقة يبقى دفتر + * `coupon_redemptions`؛ هذا العمود حارس تزامن لا محاسبة. + */ + @Column({ type: 'int', default: 0 }) + redeemed_count: number; + + /** active | disabled — الإلغاء تعطيل لا حذف، حفاظاً على دفتر الاستهلاك. */ + @Column({ type: 'varchar', default: 'active' }) + status: string; + + /** سبب المنح: `referral` · `campaign` · `support` — للتقارير التسويقية. */ + @Column({ type: 'varchar', default: 'campaign' }) + source: string; + + /** + * مرجع سبب المنح (معرّف الإحالة مثلاً) — يحرسه فهرس فريد جزئي فلا يُمنح + * كوبونان لسببٍ واحد. نظير الحارس على دفتر الرصيد تماماً: الصرف الفوري + * والماسح قد يلتقيان على نفس الإحالة، والقيد وحده يمنع مكافأة مزدوجة. + */ + @Column({ type: 'varchar', nullable: true }) + ref: string | null; + + @CreateDateColumn() + created_at: Date; + + @UpdateDateColumn() + updated_at: Date; +} diff --git a/backend/src/modules/rewards/entities/referral-code.entity.ts b/backend/src/modules/rewards/entities/referral-code.entity.ts new file mode 100644 index 0000000..e70263b --- /dev/null +++ b/backend/src/modules/rewards/entities/referral-code.entity.ts @@ -0,0 +1,41 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, +} from 'typeorm'; + +/** + * كود الدعوة الشخصي لكل مستخدم. الجدول: tripz_referral_codes. + * + * جدول مستقلّ لا عمود على `users`: الكود يحتاج فريداً على `(tenant_id, code)` + * وتعطيلاً وتدويراً، وثلاثتها أعمدة لا محلّ لها في جدول الهوية. ولأن + * `users` مشفّر الحقول، إضافة فهرس بحث جديد عليه ثمنها أعلى من جدول صغير. + */ +@Entity('referral_codes') +@Index(['tenant_id', 'code'], { unique: true }) +@Index(['tenant_id', 'user_id'], { unique: true }) +export class ReferralCode { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + tenant_id: string; + + @Column({ type: 'uuid' }) + user_id: string; + + /** بحروف كبيرة دائماً، بلا أحرف ملتبسة (0/O · 1/I) — يُملى صوتياً كثيراً. */ + @Column({ type: 'varchar' }) + code: string; + + @Column({ type: 'varchar' }) + role: 'rider' | 'driver'; + + @Column({ type: 'varchar', default: 'active' }) + status: string; + + @CreateDateColumn() + created_at: Date; +} diff --git a/backend/src/modules/rewards/entities/referral.entity.ts b/backend/src/modules/rewards/entities/referral.entity.ts new file mode 100644 index 0000000..aede9ba --- /dev/null +++ b/backend/src/modules/rewards/entities/referral.entity.ts @@ -0,0 +1,88 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; + +/** + * `pending` = سُجّلت الإحالة، والمُحال لم يستوفِ الشرط بعد. + * `qualified` = استوفى (أتمّ رحلاته المطلوبة) ولمّا تُصرف المكافأة. + * `paid` = صُرفت للطرفين. + * `rejected` = رُفضت (احتيال) — لا تُحذف، فالسجل دليل عند المراجعة. + */ +export type ReferralStatus = 'pending' | 'qualified' | 'paid' | 'rejected'; + +export type ReferralParty = 'rider' | 'driver'; + +/** + * إحالة واحدة: من دعا مَن. الجدول: tripz_referrals. + * + * الاتجاهات الأربعة (راكب→راكب · راكب→سائق · سائق→راكب · سائق→سائق) ليست + * أربعة جداول بل عمودا دور: شكل المكافأة هو ما يختلف لا شكل العلاقة — + * السائق يُكافأ رصيداً تشغيلياً، والراكب كوبوناً (محفظته لا تُسحب). + * + * **الفريد على `(tenant_id, referee_user_id)` هو الحارس الأساسي**: الشخص + * يُحال مرة واحدة في عمره. بدونه يُعاد تسجيل نفس الضحية تحت عشرة داعين. + */ +@Entity('referrals') +// الفريد معلَن على الكيان أيضاً لا في الهجرة وحدها: بيئة تُبنى بـ`synchronize` +// (الاختبارات) كانت ستفقد الحارس بالضبط حيث نظنّ أننا نختبره. +@Index(['tenant_id', 'referee_user_id'], { unique: true }) +@Index(['tenant_id', 'referrer_user_id']) +@Index(['tenant_id', 'status']) +export class Referral { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + tenant_id: string; + + @Column({ type: 'uuid' }) + referrer_user_id: string; + + @Column({ type: 'uuid' }) + referee_user_id: string; + + @Column({ type: 'varchar' }) + referrer_role: ReferralParty; + + @Column({ type: 'varchar' }) + referee_role: ReferralParty; + + /** الكود المستعمل وقت التسجيل — يُحفظ نصاً فلا يضيع المعنى لو حُذف الكود. */ + @Column({ type: 'varchar' }) + code: string; + + @Column({ type: 'varchar', default: 'pending' }) + status: ReferralStatus; + + /** كم رحلة مؤهِّلة أتمّها المُحال حتى الآن. */ + @Column({ type: 'int', default: 0 }) + qualifying_trips: number; + + /** الرحلة التي أكملت الشرط — إثبات الاستحقاق عند أي نزاع. */ + @Column({ type: 'uuid', nullable: true }) + qualified_trip_id: string | null; + + @Column({ type: 'timestamp', nullable: true }) + qualified_at: Date | null; + + @Column({ type: 'timestamp', nullable: true }) + paid_at: Date | null; + + /** سبب الرفض عند `rejected` — بلا سبب مكتوب لا تُراجَع القرارات. */ + @Column({ type: 'varchar', nullable: true }) + rejected_reason: string | null; + + @Column({ type: 'varchar', default: 'JOD' }) + currency: string; + + @CreateDateColumn() + created_at: Date; + + @UpdateDateColumn() + updated_at: Date; +} diff --git a/backend/src/modules/rewards/referrals.service.spec.ts b/backend/src/modules/rewards/referrals.service.spec.ts new file mode 100644 index 0000000..d10ac21 --- /dev/null +++ b/backend/src/modules/rewards/referrals.service.spec.ts @@ -0,0 +1,189 @@ +import { randomUUID } from 'crypto'; +import { newDb } from 'pg-mem'; +import { DataSource } from 'typeorm'; +import { Coupon } from './entities/coupon.entity'; +import { CouponRedemption } from './entities/coupon-redemption.entity'; +import { Referral } from './entities/referral.entity'; +import { ReferralCode } from './entities/referral-code.entity'; +import { CouponsService } from './coupons.service'; +import { ReferralsService } from './referrals.service'; +import { DriverCredit } from '../credit/entities/driver-credit.entity'; +import { CreditTxn } from '../credit/entities/credit-txn.entity'; +import { DriverCreditService } from '../credit/driver-credit.service'; + +const TENANT = '11111111-1111-1111-1111-111111111111'; +const REFERRER = '22222222-2222-2222-2222-222222222222'; +const REFEREE = '33333333-3333-3333-3333-333333333333'; +const DRIVER_ROW = '44444444-4444-4444-4444-444444444444'; +const TRIP = '55555555-5555-5555-5555-555555555555'; + +const fakeTenants = (settings: Record = {}) => + ({ resolve: jest.fn().mockResolvedValue({ id: TENANT, settings }) }) as any; + +/** `drivers.findByUser` هو ما يحسم شكل المكافأة — نتحكّم به لكل حالة. */ +const fakeDrivers = (driverUserIds: string[] = []) => + ({ + findByUser: jest.fn(async (_t: string, userId: string) => + driverUserIds.includes(userId) ? { id: DRIVER_ROW, user_id: userId } : null, + ), + }) as any; + +describe('ReferralsService — الإحالة والمكافآت (المجموعة L)', () => { + let ds: DataSource; + + async function build(settings: Record, driverUserIds: string[] = []) { + const db = newDb({ autoCreateForeignKeyIndices: true }); + db.public.registerFunction({ + name: 'version', + returns: 'text' as any, + implementation: () => 'pg-mem', + }); + db.public.registerFunction({ + name: 'current_database', + returns: 'text' as any, + implementation: () => 'tripz', + }); + db.registerExtension('uuid-ossp', (schema) => + schema.registerFunction({ + name: 'uuid_generate_v4', + returns: 'uuid' as any, + implementation: () => randomUUID(), + impure: true, + }), + ); + await db.public.none(`CREATE EXTENSION "uuid-ossp"`); + + ds = (await db.adapters.createTypeormDataSource({ + type: 'postgres', + entities: [Coupon, CouponRedemption, Referral, ReferralCode, DriverCredit, CreditTxn], + entityPrefix: 'tripz_', + })) as DataSource; + await ds.initialize(); + await ds.synchronize(); + + const tenants = fakeTenants(settings); + const coupons = new CouponsService( + ds.getRepository(Coupon), + ds.getRepository(CouponRedemption), + tenants, + ); + const credit = new DriverCreditService( + ds.getRepository(DriverCredit), + ds.getRepository(CreditTxn), + tenants, + ); + const drivers = fakeDrivers(driverUserIds); + const referrals = new ReferralsService( + ds.getRepository(Referral), + ds.getRepository(ReferralCode), + coupons, + credit, + drivers, + tenants, + ); + return { referrals, coupons, credit, ds }; + } + + afterEach(async () => { + if (ds?.isInitialized) await ds.destroy(); + }); + + it('لا تُصرف مكافأة عند التسجيل — الرحلة المكتملة هي الشرط', async () => { + const { referrals, coupons } = await build({}); + const code = await referrals.myCode(TENANT, REFERRER, 'rider'); + const referral = await referrals.register(TENANT, REFEREE, 'rider', code.code); + + expect(referral!.status).toBe('pending'); + // الثغرة التي يغلقها هذا الشرط: خمسون رقماً وهمياً = خمسون مكافأة. + expect(await coupons.listMine(TENANT, REFERRER)).toHaveLength(0); + expect(await coupons.listMine(TENANT, REFEREE)).toHaveLength(0); + }); + + it('الراكب يُكافأ **كوبوناً** لا رصيد محفظة — والمحفظة لا تُمسّ إطلاقاً', async () => { + const { referrals, coupons } = await build({}); + const code = await referrals.myCode(TENANT, REFERRER, 'rider'); + await referrals.register(TENANT, REFEREE, 'rider', code.code); + + await referrals.onTripCompleted(TENANT, REFEREE, TRIP); + + const forReferrer = await coupons.listMine(TENANT, REFERRER); + const forReferee = await coupons.listMine(TENANT, REFEREE); + expect(forReferrer).toHaveLength(1); + expect(forReferee).toHaveLength(1); + // القانون الثابت: محفظة الراكب لا تُسحب، فالمكافأة كوبون مسقوف لا نقد. + expect(Number(forReferrer[0].discount_value)).toBe(1); // DEFAULT_RIDER_REFERRAL_COUPON.JOD + expect(forReferrer[0].source).toBe('referral'); + expect(forReferrer[0].max_discount).not.toBeNull(); + }); + + it('السائق يُكافأ **رصيداً تشغيلياً** — شكل المكافأة يتبع الدور', async () => { + const { referrals, credit, coupons } = await build({}, [REFEREE]); + const code = await referrals.myCode(TENANT, REFERRER, 'rider'); + await referrals.register(TENANT, REFEREE, 'driver', code.code); + + await referrals.onTripCompleted(TENANT, REFEREE, TRIP); + + const balance = await credit.get(TENANT, DRIVER_ROW); + expect(Number(balance.balance)).toBe(1); // DEFAULT_DRIVER_REFERRAL_BONUS.JOD + // المُحال سائق فلا كوبون له؛ والداعي راكب فله كوبون — الاتجاه المختلط. + expect(await coupons.listMine(TENANT, REFEREE)).toHaveLength(0); + expect(await coupons.listMine(TENANT, REFERRER)).toHaveLength(1); + }); + + it('**الصرف مرتين ممنوع** — صرفٌ ثانٍ لا يمنح شيئاً إضافياً', async () => { + const { referrals, coupons, credit } = await build({}, [REFERRER]); + const code = await referrals.myCode(TENANT, REFERRER, 'driver'); + await referrals.register(TENANT, REFEREE, 'rider', code.code); + await referrals.onTripCompleted(TENANT, REFEREE, TRIP); + + const before = await credit.get(TENANT, DRIVER_ROW); + // محاكاة التقاء الماسح بالصرف الفوري على نفس الإحالة. + const qualified = await ds.getRepository(Referral).findOneOrFail({ where: {} }); + await referrals.payout({ ...qualified, status: 'qualified' } as any); + + expect(Number((await credit.get(TENANT, DRIVER_ROW)).balance)).toBe(Number(before.balance)); + expect(await coupons.listMine(TENANT, REFEREE)).toHaveLength(1); + }); + + it('الشخص يُحال **مرة واحدة** — داعٍ ثانٍ يُرفض', async () => { + const { referrals } = await build({}); + const first = await referrals.myCode(TENANT, REFERRER, 'rider'); + const other = '66666666-6666-6666-6666-666666666666'; + const second = await referrals.myCode(TENANT, other, 'rider'); + + expect(await referrals.register(TENANT, REFEREE, 'rider', first.code)).not.toBeNull(); + // بدون هذا الحارس يُعاد تسجيل نفس الضحية تحت عشرة داعين. + expect(await referrals.register(TENANT, REFEREE, 'rider', second.code)).toBeNull(); + }); + + it('إحالة النفس مرفوضة', async () => { + const { referrals } = await build({}); + const code = await referrals.myCode(TENANT, REFERRER, 'rider'); + expect(await referrals.register(TENANT, REFERRER, 'rider', code.code)).toBeNull(); + }); + + it('كود مجهول لا يرمي — التسجيل يجب ألّا يُمنع بسبب كود خاطئ', async () => { + const { referrals } = await build({}); + await expect(referrals.register(TENANT, REFEREE, 'rider', 'NOPE99')).resolves.toBeNull(); + }); + + it('شرط رحلتين: الأولى لا تؤهّل والثانية تؤهّل وتصرف', async () => { + const { referrals, coupons } = await build({ rewards: { qualifying_trips: 2 } }); + const code = await referrals.myCode(TENANT, REFERRER, 'rider'); + await referrals.register(TENANT, REFEREE, 'rider', code.code); + + await referrals.onTripCompleted(TENANT, REFEREE, TRIP); + expect(await coupons.listMine(TENANT, REFERRER)).toHaveLength(0); + + await referrals.onTripCompleted(TENANT, REFEREE, TRIP); + expect(await coupons.listMine(TENANT, REFERRER)).toHaveLength(1); + }); + + it('كود الدعوة ثابت — نداء ثانٍ يعيد نفس الكود لا كوداً جديداً', async () => { + const { referrals } = await build({}); + const a = await referrals.myCode(TENANT, REFERRER, 'rider'); + const b = await referrals.myCode(TENANT, REFERRER, 'rider'); + expect(b.code).toBe(a.code); + }); +}); + diff --git a/backend/src/modules/rewards/referrals.service.ts b/backend/src/modules/rewards/referrals.service.ts new file mode 100644 index 0000000..f10ce67 --- /dev/null +++ b/backend/src/modules/rewards/referrals.service.ts @@ -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, + @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}`, + }); + } +} diff --git a/backend/src/modules/rewards/rewards-policy.ts b/backend/src/modules/rewards/rewards-policy.ts new file mode 100644 index 0000000..6bd4bf9 --- /dev/null +++ b/backend/src/modules/rewards/rewards-policy.ts @@ -0,0 +1,96 @@ +/** + * سياسة المكافآت والكوبونات لكل مستأجر (المجموعة L — قرار المالك 2026-07-18). + * + * قانونان ثابتان يحكمان الملف كله: + * + * 1. **محفظة الراكب لا تُسحب أبداً.** فمكافأة الراكب لا تنزل رصيداً في + * `wallets` — تنزل **كوبون خصم** يخفّض `price_for_passenger` وحده بينما + * `price_for_driver` يبقى كاملاً (docs/18 §52). الفرق تتحمّله المنصة + * كتكلفة تسويق، لا يُقتطع من دخل السائق. + * 2. **كل خصم مسقوف بعملته.** السقف هنا **حدّ أعلى مطلق** لا افتراض: + * كوبون بنسبة 50% على أجرة كبيرة يبقى محكوماً بالسقف. بدونه كوبون واحد + * مكسور الإعداد يأكل أجرة بلا نهاية. + */ + +/** + * أقصى خصم لكوبون واحد لكل عملة — **قرار المالك: 25 جنيهاً في مصر، + * ومقابلها في الأردن وسوريا.** + * + * «المقابل» مشتقّ من الحدود الدنيا التي أقرّها المالك نفسه (1.10 دينار · + * 20 جنيهاً · 150 ليرة)، أي 1 دينار ≈ 18.2 جنيهاً ≈ 136 ليرة. فالسقف 25 + * جنيهاً يقابل 1.375 ديناراً و187.5 ليرة. + * + * **النسخة الأولى استعملت 1:100:100** (0.25 دينار · 25 ليرة) نقلاً عن + * `credit-policy`، وهي نسبة داخلية قديمة لا تطابق أرقام المالك: كانت تعني + * سقفاً في سوريا يساوي **سُدس** رحلة واحدة بالحد الأدنى، وفي الأردن أقلّ من + * ربع أرخص رحلة — أي عرض تسويقي لا يُرى أثره. + */ +export const DEFAULT_MAX_DISCOUNT: Record = { + JOD: 1.375, + SYP: 190, // بالعملة السورية الجديدة (187.5 مدوَّرة لخانة الليرة) + EGP: 25, +}; + +/** مكافأة إحالة السائق — تنزل رصيداً تشغيلياً (`referral_bonus`). */ +export const DEFAULT_DRIVER_REFERRAL_BONUS: Record = { + JOD: 1, + SYP: 140, + EGP: 18, +}; + +/** مكافأة إحالة الراكب — قيمة الكوبون الممنوح، وتبقى محكومة بالسقف أعلاه. */ +export const DEFAULT_RIDER_REFERRAL_COUPON: Record = { + JOD: 1, + SYP: 140, + EGP: 18, +}; + +/** صلاحية كوبون الإحالة بالأيام — منتهٍ خير من التزام مفتوح إلى الأبد. */ +export const DEFAULT_REFERRAL_COUPON_DAYS = 30; + +/** + * كم رحلة مكتملة يلزم المُحال قبل استحقاق المكافأة. + * 1 لا 0 عمداً: الصرف عند التسجيل يعني أن خمسين رقماً وهمياً = خمسون مكافأة. + */ +export const DEFAULT_QUALIFYING_TRIPS = 1; + +export interface RewardsPolicy { + /** الحدّ الأعلى المطلق لخصم الكوبون الواحد بهذه العملة. */ + maxDiscount: number; + driverReferralBonus: number; + riderReferralCoupon: number; + referralCouponDays: number; + qualifyingTrips: number; +} + +/** + * يحسب سياسة عملةٍ ما لمستأجر: الافتراض ثم تجاوز `tenants.settings.rewards`. + * إعداد مكسور (نص/سالب/NaN) يسقط للافتراض — يجب ألّا يفتح خصماً بلا سقف. + */ +export function resolveRewardsPolicy( + settings: Record | null | undefined, + currency: string, +): RewardsPolicy { + const cfg = settings?.rewards ?? {}; + return { + maxDiscount: pick(cfg.max_discount?.[currency], DEFAULT_MAX_DISCOUNT[currency] ?? 0), + driverReferralBonus: pick( + cfg.driver_referral_bonus?.[currency], + DEFAULT_DRIVER_REFERRAL_BONUS[currency] ?? 0, + ), + riderReferralCoupon: pick( + cfg.rider_referral_coupon?.[currency], + DEFAULT_RIDER_REFERRAL_COUPON[currency] ?? 0, + ), + referralCouponDays: pick(cfg.referral_coupon_days, DEFAULT_REFERRAL_COUPON_DAYS), + // الحدّ الأدنى رحلة واحدة: 0 يعني الصرف عند التسجيل وهو باب الاحتيال + // الذي بُني هذا الحقل لإغلاقه — لا يُسمح لإعدادٍ بفتحه. + qualifyingTrips: Math.max(1, pick(cfg.qualifying_trips, DEFAULT_QUALIFYING_TRIPS)), + }; +} + +/** يقبل 0 صراحةً — `||` كان سيبتلعه ويعيده للافتراض (فخّ الصفر الصادق). */ +function pick(value: any, fallback: number): number { + const n = typeof value === 'number' ? value : Number(value); + return Number.isFinite(n) && n >= 0 ? n : fallback; +} diff --git a/backend/src/modules/rewards/rewards.controller.ts b/backend/src/modules/rewards/rewards.controller.ts new file mode 100644 index 0000000..c64d0c3 --- /dev/null +++ b/backend/src/modules/rewards/rewards.controller.ts @@ -0,0 +1,148 @@ +import { + BadRequestException, + Body, + Controller, + Get, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { CouponsService } from './coupons.service'; +import { ReferralsService } from './referrals.service'; +import { TenantsService } from '../tenants/tenants.service'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator'; +import { ReferralParty } from './entities/referral.entity'; + +@ApiTags('rewards') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('rewards') +export class RewardsController { + constructor( + private readonly coupons: CouponsService, + private readonly referrals: ReferralsService, + private readonly tenants: TenantsService, + ) {} + + /** كود دعوتي + عدّاد من دعوتُهم — شاشة «ادعُ صديقاً». */ + @Get('referrals') + summary(@CurrentUser() user: AuthUser) { + return this.referrals.summary(user.tenantId, user.userId, roleOf(user)); + } + + /** كوبوناتي الصالحة — شاشة «مكافآتي». */ + @Get('coupons') + mine(@CurrentUser() user: AuthUser) { + return this.coupons.listMine(user.tenantId, user.userId); + } + + /** + * «كم سيوفّر هذا الكود؟» قبل تأكيد الرحلة — تقييم بلا استهلاك. + * + * يعيد 200 مع `ok: false` وسبب، لا خطأ 4xx: كود خاطئ حالةٌ متوقّعة في + * واجهة يكتب فيها المستخدم بيده، وليست فشلاً في الطلب. + */ + @Get('coupons/preview') + async preview( + @CurrentUser() user: AuthUser, + @Query('code') code: string, + @Query('fare') fare: string, + @Query('currency') currency = 'JOD', + ) { + const amount = Number(fare); + if (!code || !Number.isFinite(amount) || amount <= 0) { + throw new BadRequestException('code and a positive fare are required'); + } + const result = await this.coupons.evaluate( + user.tenantId, + user.userId, + code, + amount, + currency, + ); + return { + ok: result.ok, + reason: result.reason, + discount: result.discount, + capped_by: result.capped_by, + fare_after: Number((amount - result.discount).toFixed(3)), + }; + } + + /** + * حملة كوبونات جديدة — **أدمن المستأجر**. القيمة المعلنة قد تتجاوز سقف + * السياسة هنا بلا خطأ: السقف يُطبَّق وقت الحساب، فالحملة تبقى صالحة + * والخصم محكوم. رفضها كان سيمنع «50% حتى 25 جنيهاً» وهو الشكل الطبيعي. + */ + @UseGuards(RolesGuard) + @Roles('admin') + @Post('coupons') + async create( + @CurrentUser() user: AuthUser, + @Body() + body: { + code: string; + discount_type?: 'percent' | 'flat'; + discount_value: number; + max_discount?: number; + min_fare?: number; + currency?: string; + valid_until?: string; + max_redemptions?: number; + max_per_user?: number; + }, + ) { + if (!body?.code) throw new BadRequestException('code is required'); + return this.coupons.createCampaign(user.tenantId, body); + } + + /** + * سياسة المكافآت — **أدمن المستأجر**، لكل عملة. أهمّها `max_discount`: + * الحدّ الأعلى المطلق لخصم أي كوبون (قرار المالك: 25 جنيهاً في مصر + * ومقابلها في الأردن وسوريا). + */ + @UseGuards(RolesGuard) + @Roles('admin') + @Post('policy') + async setPolicy( + @CurrentUser() user: AuthUser, + @Body() + body: { + max_discount?: Record; + driver_referral_bonus?: Record; + rider_referral_coupon?: Record; + referral_coupon_days?: number; + qualifying_trips?: number; + }, + ) { + await this.tenants.updateSettings(user.tenantId, { + rewards: { + ...(body.max_discount ? { max_discount: body.max_discount } : {}), + ...(body.driver_referral_bonus + ? { driver_referral_bonus: body.driver_referral_bonus } + : {}), + ...(body.rider_referral_coupon + ? { rider_referral_coupon: body.rider_referral_coupon } + : {}), + ...(body.referral_coupon_days != null + ? { referral_coupon_days: body.referral_coupon_days } + : {}), + ...(body.qualifying_trips != null + ? { qualifying_trips: body.qualifying_trips } + : {}), + }, + }); + // نعيد **السياسة المحسوبة** لا الخام: الخام تجاوزات فقط، فكان الأدمن + // يرى حقولاً فارغة ويظنّ السياسة معطّلة (نفس درس K5). + return this.coupons.policy(user.tenantId, 'JOD'); + } +} + +/** السائق راكبٌ أيضاً في نموذج المستخدم — الدور من التوكن هو الفاصل. */ +function roleOf(user: AuthUser): ReferralParty { + return user.role === 'driver' ? 'driver' : 'rider'; +} diff --git a/backend/src/modules/rewards/rewards.module.ts b/backend/src/modules/rewards/rewards.module.ts new file mode 100644 index 0000000..83dcaa1 --- /dev/null +++ b/backend/src/modules/rewards/rewards.module.ts @@ -0,0 +1,33 @@ +import { Module, forwardRef } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Coupon } from './entities/coupon.entity'; +import { CouponRedemption } from './entities/coupon-redemption.entity'; +import { Referral } from './entities/referral.entity'; +import { ReferralCode } from './entities/referral-code.entity'; +import { CouponsService } from './coupons.service'; +import { ReferralsService } from './referrals.service'; +import { RewardsController } from './rewards.controller'; +import { RewardsSweeper } from './rewards.sweeper'; +import { CreditModule } from '../credit/credit.module'; +import { DriversModule } from '../drivers/drivers.module'; +import { TenantsModule } from '../tenants/tenants.module'; + +/** + * الإحالات والكوبونات (المجموعة L). + * + * `forwardRef` على DriversModule: الرحلات تستورد المكافآت (للخصم والتأهيل)، + * والمكافآت تستورد السائقين (لتحويل user_id إلى driver_id) — نفس الدورة + * المعروفة في CreditModule. + */ +@Module({ + imports: [ + TypeOrmModule.forFeature([Coupon, CouponRedemption, Referral, ReferralCode]), + CreditModule, + forwardRef(() => DriversModule), + TenantsModule, + ], + controllers: [RewardsController], + providers: [CouponsService, ReferralsService, RewardsSweeper], + exports: [CouponsService, ReferralsService], +}) +export class RewardsModule {} diff --git a/backend/src/modules/rewards/rewards.sweeper.ts b/backend/src/modules/rewards/rewards.sweeper.ts new file mode 100644 index 0000000..35878a6 --- /dev/null +++ b/backend/src/modules/rewards/rewards.sweeper.ts @@ -0,0 +1,62 @@ +import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { ReferralsService } from './referrals.service'; + +/** + * يصرف الإحالات المؤهَّلة التي لم تُصرف (المجموعة L — L5). + * + * **شبكة أمان لا مسار أساسي**: الصرف يقع لحظة التأهّل داخل + * `onTripCompleted`. هذا الماسح يلتقط ما سقط بينهما — انقطاع قاعدة، أو + * إعادة نشر في اللحظة الخطأ. بدونه تبقى مكافأة مستحقّة عالقة إلى الأبد + * وصاحبها لا يعرف لماذا. + * + * نفس نمط `ScheduledTripsSweeper`: `setInterval` مع قفل `running` فلا + * تتراكب دورة بطيئة مع التالية. آمن مع عدة نسخ لأن `payout` يعلّم بـUPDATE + * شرطي (`qualified → paid`)، وفوقه الفهارس الفريدة على دفتري الرصيد + * والكوبونات — فنسختان تصرفان معاً لا تمنحان مكافأة مرتين. + */ +@Injectable() +export class RewardsSweeper implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger('RewardsSweeper'); + private timer?: NodeJS.Timeout; + private running = false; + + constructor( + private readonly referrals: ReferralsService, + private readonly config: ConfigService, + ) {} + + onModuleInit(): void { + const intervalMs = this.config.get('rewards.sweepMs') ?? 60_000; + this.timer = setInterval(() => void this.tick(), intervalMs); + this.logger.log(`rewards sweeper up (every ${intervalMs}ms)`); + } + + onModuleDestroy(): void { + if (this.timer) clearInterval(this.timer); + } + + private async tick(): Promise { + if (this.running) return; + this.running = true; + try { + const due = await this.referrals.findQualified(50); + let paid = 0; + for (const referral of due) { + try { + await this.referrals.payout(referral); + paid++; + } catch (e: any) { + // إحالة واحدة فاسدة يجب ألّا توقف صرف البقية — تُسجَّل وتُعاد + // محاولتها الدورة القادمة (ما زالت `qualified`). + this.logger.error(`payout failed for ${referral.id}: ${e?.message}`); + } + } + if (paid > 0) this.logger.log(`paid ${paid} referral(s)`); + } catch (e: any) { + this.logger.error(`sweep failed: ${e?.message}`); + } finally { + this.running = false; + } + } +} diff --git a/backend/src/modules/tariff/default-tariffs.ts b/backend/src/modules/tariff/default-tariffs.ts new file mode 100644 index 0000000..d48d6fd --- /dev/null +++ b/backend/src/modules/tariff/default-tariffs.ts @@ -0,0 +1,197 @@ +import { TariffDefinition } from './entities/tariff.entity'; + +/** + * تعرفة الانطلاق الافتراضية لكل بلد — **مصدر الحقيقة الوحيد**. + * + * يستهلكها اثنان: `SeedService` (فيأخذها كل مستأجر جديد تلقائياً) ومولّد + * الـSQL (`scripts/generate-tariff-sql.ts`) لقواعد قائمة. نسختان من هذه + * الأرقام كانتا ستتباعدان بصمت، والتسعير أسوأ مكان يقع فيه هذا. + * + * هذه تعرفة **بداية** فقط؛ يُفترض أن يحدّثها كرون استخبارات الأسعار بنسخة + * أعلى (`version + 1`) لا أن يكتب فوقها. + * + * قرارات تحكم الملف (وسبب كلٍّ منها): + * + * 1. **`city = 'default'`** لا اسم البلد — كل الكود يبحث بهذه القيمة + * (`trips.service`: `dto.city ?? 'default'`). النسخة السابقة كتبت + * `'jordan'/'egypt'/'syria'` فما طابقت شيئاً: لا تعرفة ← الراكب بلا + * سعر ← `settleFare` تأخذ `quoted_fare ?? 0` ← **رحلة مجانية وعمولة + * صفر**، بسطر تحذير واحد في اللوج. البلد من `country_pack` للمستأجر. + * + * 2. **النوافذ بالتوقيت المحلي** عبر `timezone` — المحرّك كان يقرأ بـUTC، + * فذروة 16:00–20:00 تشتغل 19:00–23:00 في عمّان. + * + * 3. **فتحة العدّاد (`flag`) حقيقية** لا صفر — الفتحة تغطي تكلفة بدء + * الرحلة، وإسقاطها يجعل الرحلة القصيرة خاسرة على السائق. + * + * 4. **الحدّ الأدنى بقرار المالك**: 1.10 دينار · 20 جنيهاً · 150 ليرة. + * النسخة السابقة اشتقّته `perKm × 2` فأنتجت 0.368 ديناراً — ثلث الحقيقي. + * + * 5. **الفئات مشتقّة بمعاملات** لا أرقاماً مستقلّة — الأرقام الخام السابقة + * جعلت الفان أرخص من الاقتصادي في مصر وسوريا (22 مقابل 29.5 جنيهاً)، + * أي أن كل راكب عاقل يطلب أغلى فئة على المشغّل. + */ + +export const SERVICE_CLASSES = { + ECONOMY: 'economy', + COMFORT: 'comfort', + LADY: 'lady', + ELECTRIC: 'electric', + VAN: 'van', + DELIVERY: 'delivery', + VIP: 'vip', + FIXED: 'fixed', + SAVER: 'saver', +} as const; + +export type ServiceClass = (typeof SERVICE_CLASSES)[keyof typeof SERVICE_CLASSES]; + +/** + * معاملات الفئات نسبةً إلى الاقتصادي. الترتيب هنا **هو** منطق المنتج: + * أوفر < اقتصادي < كهربائي < ليدي < مريح < فان < VIP. + */ +export const CLASS_MULTIPLIERS: Record = { + [SERVICE_CLASSES.SAVER]: 0.85, + [SERVICE_CLASSES.DELIVERY]: 0.9, + [SERVICE_CLASSES.ECONOMY]: 1.0, + [SERVICE_CLASSES.FIXED]: 1.0, + [SERVICE_CLASSES.ELECTRIC]: 1.05, + [SERVICE_CLASSES.LADY]: 1.1, + [SERVICE_CLASSES.COMFORT]: 1.25, + [SERVICE_CLASSES.VAN]: 1.45, + [SERVICE_CLASSES.VIP]: 1.75, +}; + +/** + * الأساس الاقتصادي بالدينار — مأخوذ من التعرفة العاملة في `SeedService` + * لا من أرقام النظام القديم. + */ +const JOD_BASE = { + flag: 0.39, + perKm: 0.28, + perMin: 0.06, + waitingPerMin: 0.48, + bookingFee: 0.25, +}; + +export interface CountryTariff { + countryPack: string; + currency: string; + timezone: string; + minFare: number; + /** معامل التحويل من الدينار — مشتقّ من الحدّ الأدنى لا من سعر صرف يومي. */ + scale: number; + commissionPercent: number; + rounding: { increment: number; mode: 'nearest' | 'up' | 'down' }; + /** سعر الخط الثابت الافتراضي (فئة `fixed`) — يضبطه المشغّل لكل خط. */ + fixedFare: number; +} + +/** + * `scale` مشتقّ من الحدّ الأدنى الذي أقرّه المالك (20 ÷ 1.10 لمصر، + * 150 ÷ 1.10 لسوريا): مرساة واحدة تعني أن كل الأرقام تتحرك معاً حين + * يتغير الحدّ الأدنى، بدل جدولين ينحرف أحدهما عن الآخر. + */ +export const COUNTRY_TARIFFS: CountryTariff[] = [ + { + countryPack: 'jo', + currency: 'JOD', + timezone: 'Asia/Amman', + minFare: 1.1, + scale: 1, + commissionPercent: 14.0, + rounding: { increment: 0.05, mode: 'nearest' }, + fixedFare: 2.5, + }, + { + countryPack: 'eg', + currency: 'EGP', + timezone: 'Africa/Cairo', + minFare: 20, + scale: 20 / 1.1, + commissionPercent: 12.0, + rounding: { increment: 1, mode: 'nearest' }, + fixedFare: 45, + }, + { + countryPack: 'sy', + currency: 'SYP', + timezone: 'Asia/Damascus', + minFare: 150, + scale: 150 / 1.1, + commissionPercent: 11.0, + rounding: { increment: 5, mode: 'nearest' }, + fixedFare: 350, + }, +]; + +/** النوافذ الزمنية — بالتوقيت المحلي، وتغطي اليوم كاملاً بلا ثغرة. */ +export const WINDOWS = [ + { name: 'morning_peak', from: '07:00', to: '09:30', multiplier: 1.25 }, + { name: 'normal_day', from: '09:30', to: '16:00', multiplier: 1.0 }, + { name: 'evening_peak', from: '16:00', to: '19:30', multiplier: 1.25 }, + { name: 'normal_evening', from: '19:30', to: '23:00', multiplier: 1.0 }, + { name: 'late_night', from: '23:00', to: '07:00', multiplier: 1.15 }, +]; + +/** تدوير لخانة معقولة بحجم العملة — 3 كسور بالدينار، صحيح بالليرة. */ +function money(value: number, currency: string): number { + const decimals = currency === 'JOD' ? 3 : currency === 'EGP' ? 2 : 0; + return Number(value.toFixed(decimals)); +} + +export function countryTariff(countryPack: string): CountryTariff { + return ( + COUNTRY_TARIFFS.find((c) => c.countryPack === countryPack) ?? + COUNTRY_TARIFFS[0] // الأردن افتراضاً — نفس افتراض `country_pack` في الكيان + ); +} + +/** يبني تعريف تعرفة كامل لـ(بلد × فئة خدمة). */ +export function buildDefinition( + country: CountryTariff, + serviceClass: string, +): TariffDefinition { + const classMultiplier = CLASS_MULTIPLIERS[serviceClass] ?? 1; + const rate = (base: number, windowMultiplier: number) => + money(base * country.scale * classMultiplier * windowMultiplier, country.currency); + + const isFixed = serviceClass === SERVICE_CLASSES.FIXED; + + const definition: TariffDefinition = { + currency: country.currency, + // المنطقة الزمنية شرط صحة النوافذ — بدونها يقرأ المحرّك بـUTC. + timezone: country.timezone, + mode: isFixed ? 'fixed_quote' : 'time_and_distance', + rounding: country.rounding, + windows: WINDOWS.map((w) => ({ + name: w.name, + from: w.from, + to: w.to, + flag: rate(JOD_BASE.flag, w.multiplier), + per_km: rate(JOD_BASE.perKm, w.multiplier), + per_min: rate(JOD_BASE.perMin, w.multiplier), + // الانتظار لا يتبع معامل الفئة: دقيقة انتظار السائق واحدة مهما كانت + // فئة السيارة — الفرق في السيارة لا في الوقت. + per_min_waiting: money( + JOD_BASE.waitingPerMin * country.scale * w.multiplier, + country.currency, + ), + })), + booking_fee: money(JOD_BASE.bookingFee * country.scale, country.currency), + min_fare: country.minFare, + free_waiting_min: 5, + surge: { enabled: false }, + commission: { percent: country.commissionPercent }, + }; + + if (isFixed) { + definition.fixed_fare = country.fixedFare; + // السعر الثابت لا حدّ أدنى له ولا رسم حجز: المعلَن هو المتفَق عليه، + // ورفعه بحدّ أدنى يعني إعلان سعر وتحصيل غيره. + definition.min_fare = 0; + definition.booking_fee = 0; + } + + return definition; +} diff --git a/backend/src/modules/tariff/entities/tariff.entity.ts b/backend/src/modules/tariff/entities/tariff.entity.ts index 8dff66f..5bf8631 100644 --- a/backend/src/modules/tariff/entities/tariff.entity.ts +++ b/backend/src/modules/tariff/entities/tariff.entity.ts @@ -54,9 +54,27 @@ export interface TariffDefinition { rounding?: { increment: number; mode?: 'nearest' | 'up' | 'down' }; mode: 'time_and_distance' | 'time_or_distance' | 'fixed_quote'; speed_threshold_kmh?: number; + + /** + * منطقة التعرفة الزمنية (IANA — `Asia/Amman`). **النوافذ تُقرأ بها لا بـUTC.** + * + * غيابها كان يعني قراءة الساعة بـUTC: نافذة ذروة مكتوبة 16:00–20:00 كانت + * تشتغل فعلياً 19:00–23:00 في عمّان، أي تفوت الازدحام وتضرب السهرة. + * الافتراض `Asia/Amman` لا UTC — مستأجرونا كلهم UTC+2/+3، وUTC ليست + * منطقة أحد منهم. + */ + timezone?: string; + windows: TariffWindow[]; booking_fee?: number; min_fare?: number; + + /** + * الأجرة الثابتة لوضع `fixed_quote` (خط/وجهة بسعر متفَق عليه). + * لا مسافة ولا زمن يدخلان الحساب — الانتظار وحده يُضاف، فبقاء الراكب + * ربع ساعة عند نقطة اللقاء تكلفة حقيقية على السائق مهما كان الخط ثابتاً. + */ + fixed_fare?: number; surge?: { enabled: boolean; multiplier?: number; max?: number }; /** دقائق انتظار مجانية بعد وصول السائق قبل بدء العدّ (docs/17 — B2). القانون: 5. */ diff --git a/backend/src/modules/tariff/tariff-seed.spec.ts b/backend/src/modules/tariff/tariff-seed.spec.ts new file mode 100644 index 0000000..6fc3f5e --- /dev/null +++ b/backend/src/modules/tariff/tariff-seed.spec.ts @@ -0,0 +1,173 @@ +import { TariffEngine } from './tariff.engine'; +import { TariffDefinition } from './entities/tariff.entity'; +// نستورد مصدر الحقيقة نفسه — الذي يزرعه التطبيق ويولّد منه الـSQL — لا +// نسخة موازية منه تتباعد عنه بصمت. +import { + buildDefinition, + COUNTRY_TARIFFS as countries, + SERVICE_CLASSES as ServiceClasses, + WINDOWS, + countryTariff as byPack, +} from './default-tariffs'; + +const def = (pack: string, cls: string): TariffDefinition => + buildDefinition(byPack(pack), cls); + +/** ظهيرة يوم عادي في كل منطقة — نافذة `normal_day`. */ +const NOON_AMMAN = new Date('2026-07-19T09:00:00Z'); // 12:00 بتوقيت عمّان (+3) + +describe('تعرفة الانطلاق — الحدود الدنيا بقرار المالك', () => { + it('الحدّ الأدنى: 1.10 دينار · 20 جنيهاً · 150 ليرة', () => { + expect(def('jo', ServiceClasses.ECONOMY).min_fare).toBe(1.1); + expect(def('eg', ServiceClasses.ECONOMY).min_fare).toBe(20); + expect(def('sy', ServiceClasses.ECONOMY).min_fare).toBe(150); + }); + + it('رحلة قصيرة جداً تُرفع للحدّ الأدنى لا تُحاسَب بالعدّاد', () => { + for (const [pack, floor] of [['jo', 1.1], ['eg', 20], ['sy', 150]] as const) { + const q = TariffEngine.quote(def(pack, ServiceClasses.ECONOMY), { + distanceKm: 0.3, + durationMin: 2, + at: NOON_AMMAN, + }); + expect(q.total).toBeGreaterThanOrEqual(floor); + } + }); + + it('فتحة العدّاد موجودة في كل نافذة ولكل بلد — لا صفر', () => { + for (const country of countries) { + for (const w of def(country.countryPack, ServiceClasses.ECONOMY).windows) { + // الفتحة صفراً كانت تجعل الرحلة القصيرة خاسرة على السائق. + expect(w.flag).toBeGreaterThan(0); + } + } + }); +}); + +describe('تعرفة الانطلاق — ترتيب الفئات', () => { + // الخلل الذي كسر النسخة السابقة: الفان أرخص من الاقتصادي في مصر وسوريا، + // فيصير الخيار العقلاني لكل راكب هو أغلى فئة على المشغّل. + const ordered = [ + ServiceClasses.SAVER, + ServiceClasses.ECONOMY, + ServiceClasses.ELECTRIC, + ServiceClasses.LADY, + ServiceClasses.COMFORT, + ServiceClasses.VAN, + ServiceClasses.VIP, + ]; + + for (const country of countries) { + it(`${country.countryPack}: السعر يتصاعد مع الفئة بلا انعكاس`, () => { + const fares = ordered.map( + (cls) => + TariffEngine.quote(def(country.countryPack, cls), { + distanceKm: 5, + durationMin: 15, + at: NOON_AMMAN, + }).total, + ); + for (let i = 1; i < fares.length; i++) { + expect(fares[i]).toBeGreaterThan(fares[i - 1]); + } + }); + } +}); + +describe('النوافذ الزمنية — بالتوقيت المحلي لا UTC', () => { + it('تغطي اليوم كاملاً بلا ثغرة ولا تداخل', () => { + const toMin = (s: string) => Number(s.slice(0, 2)) * 60 + Number(s.slice(3)); + for (let i = 0; i < WINDOWS.length; i++) { + const next = WINDOWS[(i + 1) % WINDOWS.length]; + // نهاية كل نافذة = بداية التالية بالضبط؛ أي فجوة تعني دقائق بلا سعر. + expect(toMin(WINDOWS[i].to)).toBe(toMin(next.from)); + } + }); + + it('**الذروة المسائية تقع مساءً في عمّان لا بعد منتصف الليل**', () => { + const d = def('jo', ServiceClasses.ECONOMY); + // 17:00 بتوقيت عمّان = 14:00 UTC. بالحساب القديم (UTC) كانت الساعة + // تُقرأ 14:00 فتقع في `normal_day` — أي ذروة لا تُحصَّل أبداً. + const q = TariffEngine.quote(d, { + distanceKm: 5, + durationMin: 15, + at: new Date('2026-07-19T14:00:00Z'), + }); + expect(q.window).toBe('evening_peak'); + }); + + it('الظهيرة نافذة عادية، والفجر نافذة ليلية — في كل بلد بمنطقته', () => { + expect( + TariffEngine.quote(def('jo', ServiceClasses.ECONOMY), { + distanceKm: 1, + durationMin: 1, + at: new Date('2026-07-19T09:00:00Z'), // 12:00 عمّان + }).window, + ).toBe('normal_day'); + + expect( + TariffEngine.quote(def('eg', ServiceClasses.ECONOMY), { + distanceKm: 1, + durationMin: 1, + at: new Date('2026-07-19T00:30:00Z'), // 02:30 القاهرة + }).window, + ).toBe('late_night'); + }); + + it('الذروة أغلى من النافذة العادية لنفس الرحلة', () => { + const d = def('jo', ServiceClasses.ECONOMY); + const normal = TariffEngine.quote(d, { + distanceKm: 8, + durationMin: 20, + at: new Date('2026-07-19T09:00:00Z'), + }).total; + const peak = TariffEngine.quote(d, { + distanceKm: 8, + durationMin: 20, + at: new Date('2026-07-19T14:00:00Z'), + }).total; + expect(peak).toBeGreaterThan(normal); + }); +}); + +describe('السعر الثابت (fixed_quote)', () => { + it('**لا يتغير بالمسافة ولا بالزمن** — هذا معنى «ثابت»', () => { + const d = def('eg', ServiceClasses.FIXED); + const short = TariffEngine.quote(d, { distanceKm: 2, durationMin: 5, at: NOON_AMMAN }); + const long = TariffEngine.quote(d, { distanceKm: 40, durationMin: 90, at: NOON_AMMAN }); + // سابقاً كان `fixed_quote` يسقط للفرع المتري فيُحسب بالعدّاد رغم اسمه. + expect(short.total).toBe(long.total); + expect(short.total).toBe(byPack('eg').fixedFare); + }); + + it('الانتظار وحده يُضاف — وقت السائق تكلفة حقيقية مهما ثبت الخط', () => { + const d = def('eg', ServiceClasses.FIXED); + const withWait = TariffEngine.quote(d, { + distanceKm: 10, + durationMin: 20, + waitingMin: 10, + at: NOON_AMMAN, + }); + expect(withWait.total).toBeGreaterThan(byPack('eg').fixedFare); + expect(withWait.distance).toBe(0); + expect(withWait.time).toBe(0); + }); + + it('لا حدّ أدنى يرفع السعر المعلن — المعلَن هو المتفَق عليه', () => { + const d = def('sy', ServiceClasses.FIXED); + expect(d.min_fare).toBe(0); + expect( + TariffEngine.quote(d, { distanceKm: 0.5, durationMin: 1, at: NOON_AMMAN }).total, + ).toBe(byPack('sy').fixedFare); + }); +}); + +describe('العمولة', () => { + it('تُحسب بنسبة البلد على أجرة الراكب', () => { + const d = def('jo', ServiceClasses.ECONOMY); + const q = TariffEngine.quote(d, { distanceKm: 10, durationMin: 25, at: NOON_AMMAN }); + const c = TariffEngine.commission(d, q.total); + expect(c.rate).toBe(14); + expect(c.amount).toBeCloseTo(q.total * 0.14, 2); + }); +}); diff --git a/backend/src/modules/tariff/tariff.engine.ts b/backend/src/modules/tariff/tariff.engine.ts index 336e97e..74075b6 100644 --- a/backend/src/modules/tariff/tariff.engine.ts +++ b/backend/src/modules/tariff/tariff.engine.ts @@ -28,7 +28,7 @@ export interface QuoteBreakdown { export class TariffEngine { static quote(def: TariffDefinition, input: QuoteInput): QuoteBreakdown { const at = input.at ?? new Date(); - const win = TariffEngine.pickWindow(def.windows, at); + const win = TariffEngine.pickWindow(def.windows, at, def.timezone); const distanceKm = Math.max(0, input.distanceKm); const durationMin = Math.max(0, input.durationMin); const waitingMin = Math.max(0, input.waitingMin ?? 0); @@ -36,6 +36,36 @@ export class TariffEngine { let distanceCharge = 0; let timeCharge = 0; + if (def.mode === 'fixed_quote') { + // سعر ثابت متفَق عليه (خط ثابت / وجهة بسعر معلن): لا مسافة ولا زمن. + // كان يسقط سابقاً إلى الفرع المتري فيُحسب بالعدّاد رغم اسمه — أي أن + // «السعر الثابت» لم يكن ثابتاً أبداً. + const fixed = Math.max(0, def.fixed_fare ?? 0); + const waitingOnly = waitingMin * (win.per_min_waiting ?? 0); + // الانتظار وحده يُضاف: بقاء الراكب ربع ساعة تكلفة حقيقية على السائق + // مهما كان الخط ثابتاً. ولا حدّ أدنى هنا — السعر المعلن **هو** الاتفاق، + // ورفعه بحدّ أدنى يعني إعلان سعر وتحصيل غيره. + const multiplier = + (def.surge?.enabled && def.surge.multiplier ? def.surge.multiplier : 1) * + (input.geofenceMultiplier ?? 1.0); + const fixedTotal = TariffEngine.round( + (fixed + waitingOnly) * multiplier, + def.rounding, + ); + return { + window: win.name, + flag: 0, + distance: 0, + time: 0, + waiting: TariffEngine.n(waitingOnly), + bookingFee: 0, + subtotal: TariffEngine.n(fixed + waitingOnly), + surgeMultiplier: multiplier, + total: fixedTotal, + currency: def.currency, + }; + } + if (def.mode === 'time_or_distance') { // العداد المنظَّم: تحت العتبة يُحسب بالدقيقة (زحمة)، فوقها بالكيلومتر. const avgSpeed = durationMin > 0 ? (distanceKm / durationMin) * 60 : 999; @@ -46,7 +76,7 @@ export class TariffEngine { distanceCharge = distanceKm * win.per_km; } } else { - // time_and_distance (و fixed_quote يُحسب مرة ويثبّت) + // time_and_distance — العدّاد المعتاد: فتحة + مسافة + زمن. distanceCharge = distanceKm * win.per_km; timeCharge = durationMin * win.per_min; } @@ -91,7 +121,7 @@ export class TariffEngine { waitedMin: number, at: Date = new Date(), ): { freeMin: number; billableMin: number; charge: number } { - const win = TariffEngine.pickWindow(def.windows, at); + const win = TariffEngine.pickWindow(def.windows, at, def.timezone); const freeMin = def.free_waiting_min ?? 5; const billableMin = Math.max(0, (waitedMin ?? 0) - freeMin); return { @@ -114,7 +144,7 @@ export class TariffEngine { durationMin: number, at: Date = new Date(), ): number { - const win = TariffEngine.pickWindow(def.windows, at); + const win = TariffEngine.pickWindow(def.windows, at, def.timezone); const charge = Math.max(0, distanceKm ?? 0) * win.per_km + Math.max(0, durationMin ?? 0) * win.per_min; return TariffEngine.n(charge); @@ -144,8 +174,39 @@ export class TariffEngine { return { amount: TariffEngine.n(amount), rate: percent }; } - private static pickWindow(windows: TariffWindow[], at: Date): TariffWindow { - const mins = at.getUTCHours() * 60 + at.getUTCMinutes(); + /** + * دقائق اليوم بالتوقيت **المحلي للتعرفة** لا بـUTC ولا بتوقيت الخادم. + * + * `Intl` هو من يحمل جدول التوقيت الصيفي: مصر أعادت العمل به 2023، فأي + * إزاحة ثابتة محفورة في الكود كانت ستنحرف ساعةً كاملة نصف السنة — + * وساعة كاملة تعني تسعير ذروة على رحلات عادية. + */ + private static localMinutes(at: Date, timezone?: string): number { + const tz = timezone ?? 'Asia/Amman'; + try { + const parts = new Intl.DateTimeFormat('en-US', { + timeZone: tz, + hour: '2-digit', + minute: '2-digit', + hour12: false, + }).formatToParts(at); + const h = Number(parts.find((p) => p.type === 'hour')?.value); + const m = Number(parts.find((p) => p.type === 'minute')?.value); + // 24:00 بدل 00:00 يخرج من بعض الإصدارات — يُطبَّع لا يُمرَّر. + if (Number.isFinite(h) && Number.isFinite(m)) return (h % 24) * 60 + m; + } catch { + // منطقة زمنية مكتوبة خطأً في إعداد التعرفة: نسقط لتوقيت الخادم بدل + // أن نرمي — تسعير بنافذة تقريبية أهون من رحلة بلا سعر إطلاقاً. + } + return at.getHours() * 60 + at.getMinutes(); + } + + private static pickWindow( + windows: TariffWindow[], + at: Date, + timezone?: string, + ): TariffWindow { + const mins = TariffEngine.localMinutes(at, timezone); for (const w of windows) { const [fromH, fromM] = w.from.split(':').map(Number); const [toH, toM] = w.to.split(':').map(Number); diff --git a/backend/src/modules/trips/entities/trip.entity.ts b/backend/src/modules/trips/entities/trip.entity.ts index b635c11..e236e05 100644 --- a/backend/src/modules/trips/entities/trip.entity.ts +++ b/backend/src/modules/trips/entities/trip.entity.ts @@ -94,6 +94,39 @@ export class Trip { @Column({ type: 'numeric', precision: 12, scale: 3, nullable: true }) price_for_driver: number | null; + // ---- المسافة المقاسة مقابل المقدَّرة (docs/17 — B12) ---- + + /** مجموع مقاطع GPS المقبولة أثناء الرحلة — ما قُطع فعلاً. */ + @Column({ type: 'numeric', precision: 10, scale: 3, nullable: true }) + actual_distance_km: number | null; + + /** ما حُوسب عليه فعلاً (الفعلي بعد قصّ السقف). */ + @Column({ type: 'numeric', precision: 10, scale: 3, nullable: true }) + billed_distance_km: number | null; + + /** + * `actual / routed`. يُخزَّن ليُقرأ الانحراف لاحقاً بلا إعادة حساب: + * سائق نسبته 1.6 في كل رحلاته نمطٌ يستحق النظر، لا حادثة مرور. + */ + @Column({ type: 'numeric', precision: 6, scale: 3, nullable: true }) + distance_ratio: number | null; + + /** + * كود الكوبون الذي اختاره الراكب عند الطلب (المجموعة L). يُخزَّن نصاً لا + * مفتاحاً أجنبياً: الرحلة سجلّ تاريخي، فحذف حملة قديمة يجب ألّا يمحو + * سبب الخصم من رحلة مضت. + */ + @Column({ type: 'varchar', nullable: true }) + coupon_code: string | null; + + /** + * الخصم المطبَّق فعلاً (بعد السقف). يُقتطع من `price_for_passenger` وحده — + * `price_for_driver` يبقى كاملاً، فالكوبون تكلفة تسويق على المنصة لا على + * السائق (docs/18 §52). + */ + @Column({ type: 'numeric', precision: 12, scale: 3, nullable: true }) + coupon_discount: number | null; + @Column({ type: 'numeric', precision: 12, scale: 3, nullable: true }) commission_amount: number | null; diff --git a/backend/src/modules/trips/settle-fare.spec.ts b/backend/src/modules/trips/settle-fare.spec.ts new file mode 100644 index 0000000..ad9b669 --- /dev/null +++ b/backend/src/modules/trips/settle-fare.spec.ts @@ -0,0 +1,203 @@ +import { TripsService } from './trips.service'; +import { buildDefinition, countryTariff, SERVICE_CLASSES } from '../tariff/default-tariffs'; + +const TENANT = 't-1'; +const TRIP = 'trip-1'; +const RIDER = 'rider-1'; +const DRIVER = 'driver-1'; + +const DEF = buildDefinition(countryTariff('jo'), SERVICE_CLASSES.ECONOMY); +const FIXED_DEF = buildDefinition(countryTariff('jo'), SERVICE_CLASSES.FIXED); + +/** + * يبني `TripsService` بأقلّ ما يحتاجه `settleFare` — الباقي `undefined` + * عمداً: أي تبعية يلمسها هذا المسار ولم تُمرَّر ستنفجر بوضوح بدل أن تمرّ + * صامتة، وهذا هو المطلوب من اختبار مسار مالي. + */ +function build(opts: { + row: any; + def?: any; + billable?: any; + couponDiscount?: number; +}) { + const trips = { findOne: jest.fn().mockResolvedValue(opts.row) } as any; + const coupons = { + redeem: jest.fn().mockResolvedValue(opts.couponDiscount ?? 0), + } as any; + const distances = { + billableFor: jest.fn().mockResolvedValue( + opts.billable ?? { + billableKm: opts.row.distance_km, + actualKm: opts.row.distance_km, + routedKm: opts.row.distance_km, + ratio: 1, + capped: false, + points: 50, + source: 'gps', + }, + ), + } as any; + + const svc = new TripsService( + trips, + {} as any, // events + {} as any, // maps + {} as any, // tariff + {} as any, // matching + {} as any, // drivers + {} as any, // gateway + {} as any, // fraud + {} as any, // wallet + {} as any, // notifications + {} as any, // state + {} as any, // users + {} as any, // locations + {} as any, // credit + {} as any, // geofence + coupons, + {} as any, // referrals + distances, + ); + // `tariffDef` يقرأ التعرفة الفعّالة — نثبّتها لأن المقصود هنا حساب المال. + (svc as any).tariffDef = jest.fn().mockResolvedValue(opts.def ?? DEF); + return { svc, coupons, distances }; +} + +const settle = (svc: TripsService, snapshot: any) => + (svc as any).settleFare(TENANT, TRIP, snapshot) as Promise; + +const baseRow = { + id: TRIP, + rider_id: RIDER, + driver_id: DRIVER, + currency: 'JOD', + waiting_charge: 0, + is_destination_match: false, + coupon_code: null, + distance_km: 10, + duration_min: 20, + started_at: new Date('2026-07-19T09:00:00Z'), // 12:00 عمّان — نافذة عادية + completed_at: new Date('2026-07-19T09:20:00Z'), +}; + +const snapshot = { quoted_fare: 4.0 } as any; + +describe('settleFare — تسعير النهاية', () => { + it('يعيد التسعير على المسافة المقطوعة فعلاً لا على تقدير الطلب', async () => { + // قُدّرت 10 كم وقُطعت 12 (تحويلة ضمن السماحية) → الأجرة ترتفع. + const { svc } = build({ + row: baseRow, + billable: { + billableKm: 12, + actualKm: 12, + routedKm: 10, + ratio: 1.2, + capped: false, + points: 60, + source: 'gps', + }, + }); + const out = await settle(svc, snapshot); + + expect(out.billed_distance_km).toBe(12); + expect(out.actual_distance_km).toBe(12); + expect(out.distance_ratio).toBe(1.2); + // 0.39 فتحة + 12×0.28 مسافة + 20×0.06 زمن + 0.25 رسم حجز = 5.20 + expect(out.final_fare).toBeCloseTo(5.2, 2); + // السعر المقفول وقت الطلب (4.0) لم يعد هو الحاكم. + expect(out.final_fare).not.toBe(4.0); + }); + + it('نبضات قليلة → يبقى السعر المقفول، والقياس يُسجَّل للتدقيق', async () => { + const { svc } = build({ + row: baseRow, + billable: { + billableKm: 10, + actualKm: 0.3, + routedKm: 10, + ratio: 0.03, + capped: false, + points: 2, + source: 'route', + }, + }); + const out = await settle(svc, snapshot); + expect(out.final_fare).toBe(4.0); // السعر المقفول + expect(out.actual_distance_km).toBe(0.3); // مسجَّل رغم أنه لم يُحاسَب عليه + expect(out.billed_distance_km).toBeNull(); + }); + + it('**السعر الثابت لا يُعاد تسعيره بالمسافة**', async () => { + const { svc, distances } = build({ + row: { ...baseRow, distance_km: 40 }, + def: FIXED_DEF, + }); + const out = await settle(svc, snapshot); + expect(out.final_fare).toBe(4.0); + // ولا يُقرأ الـGPS أصلاً — لا معنى لقياس لا يؤثر. + expect(distances.billableFor).not.toHaveBeenCalled(); + }); + + it('الانتظار يُضاف فوق الأجرة المعاد تسعيرها', async () => { + const { svc } = build({ row: { ...baseRow, waiting_charge: 1.2 } }); + const out = await settle(svc, snapshot); + // 0.39 + 10×0.28 + 20×0.06 = 4.39 → +1.2 انتظار = 5.59 → تدوير + expect(out.final_fare).toBeGreaterThan(5.5); + }); +}); + +describe('settleFare — الكوبون وتقسيم المال', () => { + it('**الكوبون يخصم من الراكب والسائق يقبض كاملاً**', async () => { + const { svc } = build({ + row: { ...baseRow, coupon_code: 'SAVE1' }, + couponDiscount: 1.0, + }); + const out = await settle(svc, snapshot); + + expect(out.coupon_discount).toBe(1.0); + expect(out.price_for_passenger).toBeCloseTo(out.final_fare - 1.0, 3); + // القاعدة التي لا تُكسر: الخصم تكلفة تسويق على المنصة لا على السائق. + expect(out.price_for_driver).toBe(out.final_fare); + expect(out.price_for_driver).toBeGreaterThan(out.price_for_passenger); + }); + + it('**العمولة تُحتسب قبل الكوبون** — المنصة لا تدفع الخصم مرتين', async () => { + const withCoupon = await settle( + build({ row: { ...baseRow, coupon_code: 'SAVE1' }, couponDiscount: 1.0 }).svc, + snapshot, + ); + const without = await settle(build({ row: baseRow }).svc, snapshot); + // نفس الرحلة، نفس العمولة — الكوبون لا يقضم عمولة المنصة. + expect(withCoupon.commission_amount).toBe(without.commission_amount); + expect(withCoupon.commission_amount).toBeCloseTo(withCoupon.final_fare * 0.14, 2); + }); + + it('بلا كوبون: الراكب والسائق على نفس الرقم', async () => { + const out = await settle(build({ row: baseRow }).svc, snapshot); + expect(out.coupon_discount).toBeNull(); + expect(out.price_for_passenger).toBe(out.price_for_driver); + }); + + it('كوبون سقط بين الطلب والتسوية = خصم صفر لا رحلة معلّقة', async () => { + // `redeem` تُرجع 0 حين ينفد الكود أو ينتهي — التسوية تكمل بلا خصم. + const { svc } = build({ + row: { ...baseRow, coupon_code: 'DEAD' }, + couponDiscount: 0, + }); + const out = await settle(svc, snapshot); + expect(out.coupon_discount).toBeNull(); + expect(out.price_for_passenger).toBe(out.price_for_driver); + }); + + it('خصم مطابقة الوجهة والكوبون يجتمعان بلا أجرة سالبة', async () => { + const { svc } = build({ + row: { ...baseRow, is_destination_match: true, coupon_code: 'SAVE1' }, + def: { ...DEF, destination_match_discount_pct: 20 }, + couponDiscount: 1.0, + }); + const out = await settle(svc, snapshot); + expect(out.destination_match_discount).toBeGreaterThan(0); + expect(out.price_for_passenger).toBeGreaterThan(0); + expect(out.price_for_passenger).toBeLessThan(out.price_for_driver); + }); +}); diff --git a/backend/src/modules/trips/trip-distance.service.spec.ts b/backend/src/modules/trips/trip-distance.service.spec.ts new file mode 100644 index 0000000..384d1e5 --- /dev/null +++ b/backend/src/modules/trips/trip-distance.service.spec.ts @@ -0,0 +1,114 @@ +import { TripDistanceService } from './trip-distance.service'; + +/** نقطة على مسار مستقيم — ~111 متراً لكل 0.001 درجة عرض. */ +const pt = (lat: number, lng: number, secondsFromStart: number) => ({ + lat, + lng, + recorded_at: new Date(Date.UTC(2026, 6, 19, 10, 0, secondsFromStart)), +}); + +/** مسار مستقيم شمالاً بخطوات منتظمة. */ +function straightTrack(steps: number, stepDeg = 0.001, everySec = 10) { + return Array.from({ length: steps }, (_, i) => + pt(31.95 + i * stepDeg, 35.93, i * everySec), + ); +} + +describe('sumSegments — تنقية إشارات GPS', () => { + it('يجمع مساراً مستقيماً بدقة معقولة', () => { + // 10 خطوات × 0.001° ≈ 1.11 كم + const km = TripDistanceService.sumSegments(straightTrack(11)); + expect(km).toBeGreaterThan(1.0); + expect(km).toBeLessThan(1.2); + }); + + it('**يتجاهل ذبذبة الوقوف** — لا يخترع كيلومترات من جهاز واقف', () => { + // جهاز واقف عند إشارة: تذبذب أمتار قليلة، مئة نبضة. + const jitter = Array.from({ length: 100 }, (_, i) => + pt(31.95 + (i % 2) * 0.00001, 35.93, i * 5), + ); + // بلا حدّ أدنى للمقطع كان مجموع الذبذبات يصير أجرة يدفعها الراكب. + expect(TripDistanceService.sumSegments(jitter)).toBe(0); + }); + + it('**يتجاهل قفزة GPS** — نفق أو فقدان إشارة ليس مسافة مقطوعة', () => { + const points = [ + pt(31.95, 35.93, 0), + pt(31.951, 35.93, 10), + pt(32.5, 35.93, 20), // قفزة ~61 كم في 10 ثوانٍ + pt(32.501, 35.93, 30), + ]; + const km = TripDistanceService.sumSegments(points); + // المقطعان الشرعيان فقط (~0.11 + ~0.11)، والقفزة مستبعَدة. + expect(km).toBeLessThan(0.3); + }); + + it('يتجاهل المقطع بعد فجوة زمنية طويلة (انقطاع اتصال)', () => { + const points = [ + pt(31.95, 35.93, 0), + pt(31.951, 35.93, 10), + pt(31.96, 35.93, 10 + 600), // بعد 10 دقائق صمت + ]; + const km = TripDistanceService.sumSegments(points); + expect(km).toBeLessThan(0.15); // المقطع الأول وحده + }); + + it('نقطة واحدة أو لا شيء = صفر لا خطأ', () => { + expect(TripDistanceService.sumSegments([])).toBe(0); + expect(TripDistanceService.sumSegments([pt(31.95, 35.93, 0)])).toBe(0); + }); +}); + +describe('decide — المسافة المحاسَبة', () => { + it('المطابق للمسار يُحاسَب كما هو', () => { + const d = TripDistanceService.decide({ actualKm: 10, routedKm: 10, points: 50 }); + expect(d.billableKm).toBe(10); + expect(d.capped).toBe(false); + expect(d.source).toBe('gps'); + }); + + it('**التحويلة المعقولة يقبضها السائق** — أطول من التقدير ضمن السماحية', () => { + // إغلاق شارع: قطع 11.5 كم بدل 10 المقدَّرة. حرمانه منها يعني أن يخسر + // مسافةً قطعها فعلاً لأن التقدير أخطأ. + const d = TripDistanceService.decide({ actualKm: 11.5, routedKm: 10, points: 60 }); + expect(d.billableKm).toBe(11.5); + expect(d.capped).toBe(false); + }); + + it('**الجولة المخترَعة تُقصّ بالسقف** — الطول لا يصير ربحاً بلا حدّ', () => { + const d = TripDistanceService.decide({ actualKm: 25, routedKm: 10, points: 80 }); + expect(d.billableKm).toBe(12.5); // 10 × 1.25 + expect(d.capped).toBe(true); + expect(d.ratio).toBe(2.5); // يبقى مسجَّلاً للمراجعة + }); + + it('**الطريق الأقصر يُحاسَب بالأقصر** — الراكب لا يدفع تقديراً لم يُقطع', () => { + const d = TripDistanceService.decide({ actualKm: 7, routedKm: 10, points: 40 }); + expect(d.billableKm).toBe(7); + }); + + it('نبضات قليلة = رجوع للمسار المقدَّر لا لرقم مخترَع من الضجيج', () => { + const d = TripDistanceService.decide({ actualKm: 0.4, routedKm: 10, points: 3 }); + expect(d.billableKm).toBe(10); + expect(d.source).toBe('route'); + // القياس يبقى مسجَّلاً حتى حين لا يُحاسَب عليه. + expect(d.actualKm).toBe(0.4); + }); + + it('بلا نبضات إطلاقاً (GPS مطفأ) = المسار المقدَّر', () => { + const d = TripDistanceService.decide({ actualKm: 0, routedKm: 8, points: 0 }); + expect(d.billableKm).toBe(8); + expect(d.source).toBe('route'); + }); + + it('السماحية قابلة للضبط لكل مستأجر', () => { + const strict = TripDistanceService.decide({ + actualKm: 12, + routedKm: 10, + points: 50, + tolerance: 1.05, + }); + expect(strict.billableKm).toBe(10.5); + expect(strict.capped).toBe(true); + }); +}); diff --git a/backend/src/modules/trips/trip-distance.service.ts b/backend/src/modules/trips/trip-distance.service.ts new file mode 100644 index 0000000..2c684e4 --- /dev/null +++ b/backend/src/modules/trips/trip-distance.service.ts @@ -0,0 +1,181 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Between, Repository } from 'typeorm'; +import { DriverTrack } from '../locations/entities/driver-track.entity'; +import { MapsService } from '../maps/maps.service'; + +/** + * أقصر مسافة بين نبضتين تُحتسب. أقل منها ضجيج GPS لا حركة: جهاز واقف عند + * إشارة مرور يتذبذب أمتاراً، وجمع الذبذبات على رحلة طويلة يخترع كيلومترات + * لم تُقطَع — والراكب يدفعها. + */ +const MIN_SEGMENT_KM = 0.01; + +/** + * أقصى مسافة بين نبضتين متتاليتين تُقبل. أطول منها قفزة GPS (نفق، فقدان + * إشارة، إعادة اتصال) لا مسافة مقطوعة — تُتجاهل بدل أن تُضاف للأجرة. + */ +const MAX_SEGMENT_KM = 2; + +/** أقصى فجوة زمنية بين نبضتين قبل اعتبار المقطع منقطعاً. */ +const MAX_SEGMENT_GAP_MS = 120_000; + +export interface BillableDistance { + /** ما ستُحسب عليه الأجرة فعلاً. */ + billableKm: number; + /** مجموع مقاطع GPS المقبولة. */ + actualKm: number; + /** ما قدّره المزوّد وقت الطلب. */ + routedKm: number; + /** `actual / routed` — 1.0 يعني مطابقة تامة. */ + ratio: number; + /** هل قُصّت المسافة بالسقف؟ (مؤشر تحقيق لا اتهام). */ + capped: boolean; + /** عدد نبضات GPS المستعملة — قليلها يعني ثقة أقل. */ + points: number; + source: 'gps' | 'route'; +} + +/** + * المسافة المحتسَبة للرحلة: إشارات GPS مقابل المسار المقدَّر (نظير + * `car_tracks` عند سيرو). + * + * **لماذا لا نكتفي بمسافة المسار؟** لأنها تقدير وقت الطلب: إغلاق شارع أو + * تحويلة تجعل السائق يقطع أكثر مما قُدّر، فيخسر فرق مسافة قطعها فعلاً. + * + * **ولماذا لا نكتفي بالـGPS؟** لأن الرحلة الأطول تعني أجرة أعلى، فالتجوال + * بلا داعٍ يصير ربحاً. القاعدة هنا: **يُحاسَب الأطول من الاثنين، بسقف + * `routed × tolerance`.** فالسائق يقبض تحويلته الحقيقية، ولا يقبض جولةً + * اخترعها. وما تجاوز السقف يُعلَّم للمراجعة لا يُخصم صامتاً. + * + * والانحراف تحت المسار (طريق أقصر) يُحاسَب بالأقصر — الراكب لا يدفع تقديراً + * أعلى مما قُطع. + */ +@Injectable() +export class TripDistanceService { + private readonly logger = new Logger('TripDistance'); + + constructor( + @InjectRepository(DriverTrack) + private readonly tracks: Repository, + ) {} + + /** + * يجمع مقاطع GPS بين لحظتين لسائق. **دالة صافية على المدخلات** بعد القراءة، + * فمنطق التصفية قابل للاختبار وحده عبر `sumSegments`. + */ + async actualDistanceKm( + tenantId: string, + driverId: string, + from: Date, + to: Date, + ): Promise<{ km: number; points: number }> { + const points = await this.tracks.find({ + where: { + tenant_id: tenantId, + driver_id: driverId, + recorded_at: Between(from, to), + }, + order: { recorded_at: 'ASC' }, + }); + return { km: TripDistanceService.sumSegments(points), points: points.length }; + } + + /** مجموع المقاطع المقبولة — الضجيج والقفزات تُستبعد (دالة صافية). */ + static sumSegments( + points: Array>, + ): number { + let total = 0; + for (let i = 1; i < points.length; i++) { + const a = points[i - 1]; + const b = points[i]; + const gap = new Date(b.recorded_at).getTime() - new Date(a.recorded_at).getTime(); + if (!(gap >= 0) || gap > MAX_SEGMENT_GAP_MS) continue; + + const seg = MapsService.haversineKm( + { lat: Number(a.lat), lng: Number(a.lng) }, + { lat: Number(b.lat), lng: Number(b.lng) }, + ); + if (!Number.isFinite(seg) || seg < MIN_SEGMENT_KM || seg > MAX_SEGMENT_KM) continue; + total += seg; + } + return Number(total.toFixed(3)); + } + + /** + * يقرّر المسافة المحاسَبة — **دالة صافية** لتُختبر بلا قاعدة. + * + * `minPoints` حارس ثقة: رحلة بأربع نبضات لا تكفي لبناء مسار، والاعتماد + * عليها كان سيحاسب راكباً على مسافة مخترَعة من ضجيج. عندها نرجع للمسار + * المقدَّر — وهو أسوأ ما يمكن أن يحدث: تقدير معقول بدل رقم مختلق. + */ + static decide(params: { + actualKm: number; + routedKm: number; + points: number; + tolerance?: number; + minPoints?: number; + }): BillableDistance { + const tolerance = params.tolerance ?? 1.25; + const minPoints = params.minPoints ?? 5; + const routedKm = Math.max(0, Number(params.routedKm) || 0); + const actualKm = Math.max(0, Number(params.actualKm) || 0); + const ratio = routedKm > 0 ? Number((actualKm / routedKm).toFixed(3)) : 0; + + if (params.points < minPoints || actualKm <= 0) { + return { + billableKm: routedKm, + actualKm, + routedKm, + ratio, + capped: false, + points: params.points, + source: 'route', + }; + } + + const ceiling = routedKm * tolerance; + const capped = actualKm > ceiling && routedKm > 0; + const billableKm = Number(Math.min(actualKm, capped ? ceiling : actualKm).toFixed(3)); + + return { + billableKm, + actualKm, + routedKm, + ratio, + capped, + points: params.points, + source: 'gps', + }; + } + + /** يجمع القراءة والقرار — ما تناديه تسوية الأجرة. */ + async billableFor(params: { + tenantId: string; + driverId: string; + from: Date; + to: Date; + routedKm: number; + tolerance?: number; + }): Promise { + const { km, points } = await this.actualDistanceKm( + params.tenantId, + params.driverId, + params.from, + params.to, + ); + const decision = TripDistanceService.decide({ + actualKm: km, + routedKm: params.routedKm, + points, + tolerance: params.tolerance, + }); + if (decision.capped) { + this.logger.warn( + `distance capped: driver=${params.driverId} actual=${decision.actualKm} ` + + `routed=${decision.routedKm} ratio=${decision.ratio} → billed ${decision.billableKm}`, + ); + } + return decision; + } +} diff --git a/backend/src/modules/trips/trips.module.ts b/backend/src/modules/trips/trips.module.ts index c1c25a8..c25ebd2 100644 --- a/backend/src/modules/trips/trips.module.ts +++ b/backend/src/modules/trips/trips.module.ts @@ -17,10 +17,13 @@ import { UsersModule } from '../users/users.module'; import { LocationsModule } from '../locations/locations.module'; import { CreditModule } from '../credit/credit.module'; import { GeofenceModule } from '../geofence/geofence.module'; +import { RewardsModule } from '../rewards/rewards.module'; +import { TripDistanceService } from './trip-distance.service'; +import { DriverTrack } from '../locations/entities/driver-track.entity'; @Module({ imports: [ - TypeOrmModule.forFeature([Trip, TripEvent]), + TypeOrmModule.forFeature([Trip, TripEvent, DriverTrack]), MapsModule, TariffModule, MatchingModule, @@ -32,9 +35,10 @@ import { GeofenceModule } from '../geofence/geofence.module'; LocationsModule, CreditModule, GeofenceModule, + RewardsModule, ], controllers: [TripsController], - providers: [TripsService, TripStateService, ScheduledTripsSweeper], + providers: [TripsService, TripStateService, ScheduledTripsSweeper, TripDistanceService], exports: [TripsService], }) export class TripsModule {} diff --git a/backend/src/modules/trips/trips.service.ts b/backend/src/modules/trips/trips.service.ts index ccdf915..ca968c5 100644 --- a/backend/src/modules/trips/trips.service.ts +++ b/backend/src/modules/trips/trips.service.ts @@ -12,6 +12,7 @@ import { TripEvent } from './entities/trip-event.entity'; import { MapsService } from '../maps/maps.service'; import { TariffService } from '../tariff/tariff.service'; import { TariffEngine } from '../tariff/tariff.engine'; +import { TariffDefinition } from '../tariff/entities/tariff.entity'; import { MatchingService } from '../matching/matching.service'; import { DriversService } from '../drivers/drivers.service'; import { RealtimeGateway } from '../../realtime/realtime.gateway'; @@ -59,9 +60,14 @@ export interface RequestTripDto { rider_id?: string; // يُستخدم من dispatch لإنشاء رحلة نيابةً عن راكب stops?: Array<{ lat: number; lng: number; label?: string }>; // docs/17 — B4 scheduled_at?: string; // ISO — حجز مسبق (docs/17 — B5) + payment_method?: string; + coupon_code?: string; // كوبون خصم — يُقيَّم الآن ويُستهلك عند التسوية (L) } import { GeofenceService } from '../geofence/geofence.service'; +import { CouponsService } from '../rewards/coupons.service'; +import { ReferralsService } from '../rewards/referrals.service'; +import { TripDistanceService } from './trip-distance.service'; @Injectable() export class TripsService { @@ -83,6 +89,9 @@ export class TripsService { private readonly locations: DriverLocationService, private readonly credit: DriverCreditService, private readonly geofence: GeofenceService, + private readonly coupons: CouponsService, + private readonly referrals: ReferralsService, + private readonly distances: TripDistanceService, ) {} get(tenantId: string, id: string): Promise { @@ -199,6 +208,22 @@ export class TripsService { this.logger.warn(`no tariff for ${city}/${serviceClass} — trip without quote`); } + // الكوبون يُقيَّم الآن ليعرف الراكب سعره قبل التأكيد، لكنه **لا يُستهلك** + // هنا: رحلة قد تُلغى، واستهلاكه عند الطلب كان يحرق الكود بلا رحلة. + // الاستهلاك في `settleFare` وحدها. + let couponCode: string | null = null; + if (dto.coupon_code && quotedFare != null) { + const evaluated = await this.coupons.evaluate( + tenantId, + riderId, + dto.coupon_code, + quotedFare, + currency ?? 'JOD', + ); + if (evaluated.ok) couponCode = evaluated.coupon!.code; + else this.logger.warn(`coupon ${dto.coupon_code} rejected at request: ${evaluated.reason}`); + } + let trip = this.trips.create({ tenant_id: tenantId, rider_id: riderId, @@ -223,7 +248,8 @@ export class TripsService { commission_amount: commissionAmount, commission_rate: commissionRate, currency: currency ?? undefined, - payment_method: (dto as any).payment_method ?? 'cash', + payment_method: dto.payment_method ?? 'cash', + coupon_code: couponCode, }); trip = await this.trips.save(trip); @@ -427,15 +453,40 @@ export class TripsService { } } + // تأهيل الإحالات عند الإنهاء (المجموعة L — L4): المكافأة تُستحقّ برحلة + // مكتملة لا بتسجيل. الطرفان يُفحصان لأن الاتجاهات أربعة — قد يكون + // المُحال هو الراكب أو السائق. + if (toStatus === 'completed') { + for (const userId of [trip.rider_id, snapshot.driver_user_id]) { + if (!userId) continue; + try { + await this.referrals.onTripCompleted(tenantId, userId, trip.id); + } catch (e: any) { + // فشل المكافأة يجب ألّا يُسقط إنهاء الرحلة — الماسح يلتقطها لاحقاً. + this.logger.error(`referral qualify failed for ${userId}: ${e?.message}`); + } + } + } + // تسوية المحفظة عند الدفع (payment_method === wallet): المبلغ ينتقل من // الراكب للسائق **كاملاً**. العمولة لا تُقتطع هنا — خُصمت من الرصيد // التشغيلي عند الإنهاء (docs/18 §5.5: قاعدة واحدة للكاش والمحفظة). if (toStatus === 'paid' && trip.payment_method === 'wallet' && trip.final_fare != null) { - const fare = Number(trip.price_for_passenger ?? trip.final_fare); + // الطرفان لم يعودا متساويين بعد الكوبون: الراكب يدفع المخفَّض والسائق + // يقبض الكامل. الفرق تتحمّله المنصة — ولذلك **لا يجوز** اشتقاق أحدهما + // من الآخر هنا، فالسائق كان سيخسر خصماً لم يوافق عليه. + const riderPays = Number(trip.price_for_passenger ?? trip.final_fare); + const driverGets = Number(trip.price_for_driver ?? trip.final_fare); try { - await this.wallet.debit(tenantId, trip.rider_id, fare, 'trip_fare', trip.id); - if (snapshot.driver_user_id && fare > 0) { - await this.wallet.credit(tenantId, snapshot.driver_user_id, fare, 'trip_earning', trip.id); + await this.wallet.debit(tenantId, trip.rider_id, riderPays, 'trip_fare', trip.id); + if (snapshot.driver_user_id && driverGets > 0) { + await this.wallet.credit( + tenantId, + snapshot.driver_user_id, + driverGets, + 'trip_earning', + trip.id, + ); } } catch (e: any) { this.logger.warn(`wallet settle failed: ${e?.message}`); @@ -659,15 +710,33 @@ export class TripsService { // الانتظار ومشوار الوصول حُسبا وخُزّنا سابقاً — نقرأهما من الصف. const row = await this.trips.findOne({ where: { tenant_id: tenantId, id: tripId }, - select: { waiting_charge: true, pickup_charge: true, is_destination_match: true }, + select: { + waiting_charge: true, + pickup_charge: true, + is_destination_match: true, + coupon_code: true, + rider_id: true, + currency: true, + id: true, + driver_id: true, + started_at: true, + completed_at: true, + distance_km: true, + duration_min: true, + }, }); - const base = snapshot.quoted_fare ?? 0; - const waiting = Number(row?.waiting_charge ?? 0); - // مشوار الوصول لا يدخل أجرة الرحلة — هو تعويض إلغاء فقط (docs/18، تصحيح B3). - const gross = base + waiting; const def = await this.tariffDef(tenantId, snapshot); + // إعادة التسعير على **المسافة المقطوعة فعلاً** لا على تقدير وقت الطلب + // (نظير `car_tracks` عند سيرو). تحويلة حقيقية يقبضها السائق، وجولة + // مخترَعة يقصّها السقف. التفاصيل في `TripDistanceService`. + const priced = await this.repriceOnActualDistance(tenantId, def, row, snapshot); + + const waiting = Number(row?.waiting_charge ?? 0); + // مشوار الوصول لا يدخل أجرة الرحلة — هو تعويض إلغاء فقط (docs/18، تصحيح B3). + const gross = priced.fare + waiting; + // خصم مطابقة الوجهة (docs/17 — B8): الرحلة توافق اتجاه السائق فيُخفَّض // على الراكب. الخصم من أجرة الراكب لا من دخل السائق — السائق يقبض الأجرة // المخفَّضة كاملة (يربح رحلةً في طريقه أصلاً). @@ -677,20 +746,103 @@ export class TripsService { } const total = Number((gross - discount).toFixed(3)); + // العمولة على الأجرة **قبل** خصم الكوبون: الكوبون تنازل تسويقي من + // المنصة، فاحتسابه بعده كان يجعل المنصة تدفع الخصم مرتين — مرة للراكب + // ومرة بعمولة أنقص من السائق. const commission = def ? TariffEngine.commission(def, total) : { amount: 0, rate: 0 }; + // الكوبون آخر خطوة، وعلى جانب الراكب وحده (المجموعة L). الاستهلاك هنا + // لا عند الاقتباس: الاقتباس يتكرر مع كل تغيير وجهة، والاستهلاك مرة. + let couponDiscount = 0; + if (row?.coupon_code) { + couponDiscount = await this.coupons.redeem( + tenantId, + row.rider_id, + row.coupon_code, + tripId, + total, + row.currency ?? 'JOD', + ); + } + const riderPays = Number((total - couponDiscount).toFixed(3)); + return { final_fare: total, destination_match_discount: discount || null, - // السائق يقبض ما يدفعه الراكب **كاملاً** (docs/18). العمولة تُخصم من - // رصيده التشغيلي لا من هذا المبلغ. - price_for_passenger: total, + coupon_discount: couponDiscount || null, + actual_distance_km: priced.actualKm, + billed_distance_km: priced.billedKm, + distance_ratio: priced.ratio, + // الراكب يدفع بعد الكوبون، والسائق يقبض الأجرة كاملة **قبله**: الخصم + // تكلفة تسويق على المنصة لا اقتطاع من دخل السائق (docs/18 §52). + // والعمولة تُخصم من رصيده التشغيلي لا من هذا المبلغ (docs/18). + price_for_passenger: riderPays, price_for_driver: total, commission_amount: commission.amount, commission_rate: commission.rate, }; } + /** + * يعيد تسعير الرحلة على المسافة المقطوعة فعلاً (docs/17 — B12). + * + * **لا يفشل أبداً**: كل مسار خطأ يعود للسعر المقفول وقت الطلب. الرحلة تمّت + * والراكب واقف ينتظر فاتورته — تعذّر قراءة نبضات GPS يجب ألّا يترك رحلة + * بلا أجرة. وسعر الطلب رقم معلن سبق أن رآه الراكب، فهو أسلم رجعة ممكنة. + */ + private async repriceOnActualDistance( + tenantId: string, + def: TariffDefinition | null | undefined, + row: Partial | null, + snapshot: TripState, + ): Promise<{ fare: number; actualKm: number | null; billedKm: number | null; ratio: number | null }> { + const quoted = snapshot.quoted_fare ?? 0; + const fallback = { fare: quoted, actualKm: null, billedKm: null, ratio: null }; + + // السعر الثابت لا يُعاد تسعيره بالمسافة — هذا معنى «ثابت» (المسافة + // تُقاس وتُخزَّن للتدقيق، لكنها لا تمسّ الأجرة). + if (!def || def.mode === 'fixed_quote') return fallback; + if (!row?.driver_id || !row.started_at || !row.completed_at) return fallback; + + const routedKm = Number(row.distance_km ?? 0); + if (!(routedKm > 0)) return fallback; + + try { + const decision = await this.distances.billableFor({ + tenantId, + driverId: row.driver_id, + from: new Date(row.started_at), + to: new Date(row.completed_at), + routedKm, + }); + if (decision.source === 'route') { + // نبضات أقل من أن يُبنى عليها مسار — نُبقي السعر المقفول ونسجّل ما + // قِيس، فالرقم يبقى للتدقيق حتى حين لا يُحاسَب عليه. + return { ...fallback, actualKm: decision.actualKm, ratio: decision.ratio }; + } + + // الزمن الفعلي من الانطلاق للإنهاء — لا تقدير المزوّد. الزحمة الحقيقية + // جزء من الأجرة في وضع الزمن+المسافة. + const actualMin = + (new Date(row.completed_at).getTime() - new Date(row.started_at).getTime()) / 60000; + + const q = TariffEngine.quote(def, { + distanceKm: decision.billableKm, + durationMin: Math.max(0, Number(actualMin.toFixed(1))), + at: new Date(row.started_at), + }); + return { + fare: q.total, + actualKm: decision.actualKm, + billedKm: decision.billableKm, + ratio: decision.ratio, + }; + } catch (e: any) { + this.logger.error(`reprice failed for trip ${row?.id ?? 'unknown'}: ${e?.message}`); + return fallback; + } + } + private assertTransition(from: TripStatus, to: TripStatus) { if (!(TRANSITIONS[from] ?? []).includes(to)) { throw new BadRequestException(`Invalid transition ${from} -> ${to}`); diff --git a/docs/17-backend-backlog.md b/docs/17-backend-backlog.md index 1b03e41..dfdc1ac 100644 --- a/docs/17-backend-backlog.md +++ b/docs/17-backend-backlog.md @@ -277,6 +277,67 @@ --- +## المجموعة L — الإحالات والمكافآت والكوبونات — ✅ منفَّذة +> قرار المالك 2026-07-18. **قانونان ثابتان يحكمان المجموعة كلها:** +> 1. **محفظة الراكب لا تُسحب أبداً** — فمكافأة الراكب **كوبون خصم** لا رصيد محفظة. إنزالها في المحفظة كان يخلق التزاماً نقدياً قابلاً للسحب مقابل حملة تسويقية. +> 2. **كل خصم مسقوف بعملته** — 25 جنيهاً في مصر · 25 ليرة سورية · 0.25 دينار (نفس نسبة `credit-policy`). السقف **حدّ أعلى مطلق** لا افتراض. + +| # | البند | الحالة | +|---|-------|--------| +| L1 | `referral_codes` (كود شخصي ثابت) + `referrals` بحالات `pending → qualified → paid` | ✅ | +| L2 | نوع قيد `referral_bonus` في `credit_txns` + `grantReferralBonus` بحارسَي J6 (فحص تطبيقي + فهرس فريد جزئي على `ref`) | ✅ | +| L3 | محرّك الكوبونات: `coupons` + `coupon_redemptions`. يخصم من `price_for_passenger` وحده — `price_for_driver` يبقى كاملاً (docs/18 §52) | ✅ | +| L4 | **شرط الاستحقاق**: بعد أول رحلة مكتملة للمُحال لا عند التسجيل. `qualifying_trips` لا ينزل تحت 1 ولو ضبطه الأدمن صفراً | ✅ | +| L5 | `RewardsSweeper` — نمط `ScheduledTripsSweeper` (setInterval + قفل `running`). **شبكة أمان لا مسار أساسي**: الصرف يقع لحظة التأهّل | ✅ | +| L6 | `tenants.settings.rewards` بنفس بنية `credit-policy` — سقف الخصم ومكافآت الطرفين لكل عملة | ✅ | +| L7 | ربط `fraud.service` — كشف حلقات الإحالة والأجهزة المكرّرة | ⏳ | +| L8 | باقات الشحن (J5) فوق نفس المحرّك | ⏳ | + +**اتجاهات الإحالة الأربعة ليست أربعة مسارات**: شكل المكافأة يتبع **دور المستفيد** لا اتجاه الإحالة — سائق ← رصيد تشغيلي، راكب ← كوبون. والدور يُحسم **وقت الصرف** من وجود صفّ سائق لا من الدور المخزَّن وقت التسجيل، فمن سجّل بكود دعوة ثم تقدّم سائقاً يستحقّ رصيداً لا كوبوناً. + +**حرّاس الاحتيال (على القاعدة لا بالفحص التطبيقي وحده)**: +- فريد على `(tenant_id, referee_user_id)` — الشخص يُحال **مرة واحدة** في عمره. بدونه تُعاد نفس الضحية تحت عشرة داعين. +- `CHECK (referrer <> referee)` — إحالة النفس. +- فريد جزئي على `credit_txns(tenant_id, ref) WHERE type='referral_bonus'` وعلى `coupons(tenant_id, ref)` — الماسح والصرف الفوري قد يلتقيان على إحالة واحدة. +- فريد على `coupon_redemptions(tenant_id, trip_id)` — كوبون واحد لكل رحلة. + +**درس مثبَّت باختبار**: المحاولة الأولى حسبت عتبة التأهّل داخل SQL (`CASE WHEN qualifying_trips + 1 >= n`)، فجُمع العمود **نصّاً** (`'0' + 1 = '01'`) ولم تتحقق الشرطية أبداً — كل إحالة تبقى معلّقة بلا مكافأة، وهو فشل صامت لا يُكتشف إلا بشكوى مستخدم. الحساب صار في TypeScript عبر `RETURNING`، والتزامن محفوظ بشرط الحالة في كلتا العبارتين. + +**العمولة تُحتسب قبل خصم الكوبون** عمداً: الكوبون تنازل تسويقي من المنصة، فاحتسابها بعده كان يجعل المنصة تدفع الخصم مرتين — مرة للراكب ومرة بعمولة أنقص من السائق. + +--- + +## المجموعة M — أساس التسعير: تعرفة الانطلاق والمسافة الحقيقية — ✅ منفَّذة +> قرار المالك 2026-07-19. **الحدّ الأدنى للرحلة: 1.10 دينار · 20 جنيهاً · 150 ليرة** — وهذه المرساة التي تُشتقّ منها كل الأرقام الأخرى. + +| # | البند | الحالة | +|---|-------|--------| +| M1 | `default-tariffs.ts` — مصدر حقيقة واحد يستهلكه `SeedService` ومولّد الـSQL معاً | ✅ | +| M2 | `city = 'default'` بدل اسم البلد؛ البلد من `country_pack` للمستأجر | ✅ | +| M3 | نوافذ التعرفة **بالتوقيت المحلي** (`timezone` في التعريف) عبر `Intl` — لا UTC ولا توقيت الخادم | ✅ | +| M4 | فتحة العدّاد (`flag`) حقيقية في كل نافذة، ورسم حجز (`booking_fee`) | ✅ | +| M5 | `fixed_quote` منفَّذ فعلاً — لا مسافة ولا زمن ولا حدّ أدنى؛ الانتظار وحده يُضاف | ✅ | +| M6 | الفئات مشتقّة بمعاملات: أوفر < اقتصادي < كهربائي < ليدي < مريح < فان < VIP | ✅ | +| M7 | `TripDistanceService` — المسافة من نبضات GPS مع تنقية الضجيج والقفزات | ✅ | +| M8 | إعادة التسعير عند الإنهاء على المسافة المقطوعة فعلاً، بسقف `routed × 1.25` | ✅ | +| M9 | زرع التعرفة **لكل فئة خدمة** لا الاقتصادي وحده | ✅ | +| M10 | سماحية المسافة لكل مستأجر + ربط الانحراف بـ`fraud.service` | ⏳ | + +**الأعطال التي أُصلحت (كلها كانت فشلاً صامتاً)**: +- **رحلة مجانية**: السكربت القديم كتب `city='jordan'`، والكود يبحث بـ`'default'` → لا تعرفة → `quotedFare = null` → `settleFare` تأخذ `quoted_fare ?? 0` → **أجرة صفر وعمولة صفر**، بسطر تحذير واحد في اللوج. +- **ذروة لا تُحصَّل أبداً**: `pickWindow` قرأ `getUTCHours()`، فذروة 16:00–20:00 تقع 19:00–23:00 في عمّان. +- **الفان أرخص من الاقتصادي** في مصر وسوريا (22 مقابل 29.5 جنيهاً لنفس الرحلة) — أي أن الخيار العقلاني لكل راكب هو أغلى فئة على المشغّل. +- **حدّ أدنى ثلث الحقيقي**: `perKm × 2` = 0.368 ديناراً بدل 1.10. +- **`fixed_quote` لم يكن ثابتاً**: كان يسقط للفرع المتري فيُحسب بالعدّاد رغم اسمه. +- **السكربت لم يعمل أصلاً**: `require` في ملف ESM؛ ولا وجود لـ`seed-tariffs.sql` إطلاقاً. +- **تشغيلتان = تعرفتان فعّالتان** بنفس النسخة، و`getActive` ترتّب `version DESC` فالاختيار بينهما غير محدَّد. + +**سياسة المسافة (M7/M8)**: يُحاسَب **الأطول** من (المقاس، المقدَّر) بسقف `routed × 1.25`. التحويلة الحقيقية يقبضها السائق، والجولة المخترَعة تُقصّ وتُعلَّم للمراجعة لا تُخصم صامتة. والطريق الأقصر يُحاسَب بالأقصر — الراكب لا يدفع تقديراً لم يُقطع. نبضات أقل من 5 = رجوع للسعر المقفول: رقم مخترَع من الضجيج أسوأ من تقدير معقول. + +**تصحيح على المجموعة L**: سقوف الكوبونات كانت مبنية على نسبة `credit-policy` القديمة (1:100:100)، وهي لا تطابق مرساة المالك (1.10 = 20 = 150، أي 1 دينار ≈ 18.2 جنيهاً ≈ 136 ليرة). السقف 25 جنيهاً صار يقابل **1.375 ديناراً و190 ليرة** بدل 0.25 و25 — النسخة الأولى كانت تعطي سوريا سُدس العرض. + +--- + ## مؤجَّل عمداً (قرار المالك) المفاوض الذكي · تدرّج السائق · خصم العمولة — **آخر شيء** (جديدة حتى على سيرو). **Geofence** — مؤجَّل («لوقتها»)، موجود في سيرو للاستئناس. diff --git a/docs/22-full-product-roadmap.md b/docs/22-full-product-roadmap.md index 6ff3d45..eb75607 100644 --- a/docs/22-full-product-roadmap.md +++ b/docs/22-full-product-roadmap.md @@ -116,6 +116,9 @@ Shorebird يرقّع Dart فقط. بتوحيد Kotlin/الصلاحيات/NDK ت | N4 | **لوحة أدمن المستأجر (ويب)**: سائقون (اعتماد) · رحلات · مراجعة وثائق · سحوبات (تحويل/فشل). | ✅ `dashboards/admin-web/index.html` + نقاط سرد إدارية (`/drivers/admin/list` · `/trips/admin/list` · `/payouts/admin/list` خلف RolesGuard admin/dispatcher). التعرفة والتقارير تُضاف مع L. | | N5 | **لوحة خدمة العملاء (ويب)**: بحث مستخدم برقمه (فهرس أعمى) · رحلاته · تفاصيل رحلة بالمعرّف. | ✅ `dashboards/service-web/index.html` + `GET /admin/users/search` · `/trips/admin/list?rider=`. الشكاوى تحتاج وحدة complaints (لا توجد بعد — بند لاحق). | +| N6 | **كتالوج تسعير الميزات** (قرار المالك 2026-07-19): جدول `feature_catalog` — المفتاح · الاسم العربي · الفئة (نواة/باقة/add-on) · **سعر شهري** · رسم إعداد · الوصف — والسوبر-أدمن يحرّر الأسعار من اللوحة. يوسّع K2: الكتالوج نفسه يغذّي الاستحقاقات الافتراضية والتسعير معاً. الإضافات المسعّرة أولاً: استخبار السوق · البوتات/التسويق · التسعير الديناميكي · المواصلات · المفاوض · شرائح السائقين · تأمين السائق. | ⬜ | +| N7 | **مولّد عرض السعر (الفوترة التعاقدية)**: في لوحة السوبر-أدمن — اختيار باقة + إضافات ⇒ عرض سعر مجمّع (إعداد مرة واحدة + شهري) يُطبع/يُرسل للعميل عند التعاقد. عند الاعتماد: تُكتب `tenants.features` آلياً من البنود المختارة (تكامل K3 — لا إدخال يدوي مزدوج ينسى ميزة) + سجل اشتراك شهري يظهر في overview (N1b). | ⬜ | + **مصادقة اللوحتين**: هاتف + OTP → JWT بدور admin/dispatcher (نفس تدفّق الموبايل). كل الحماية على السيرفر (RolesGuard) — الواجهة عرض فقط. الوصول للوحة معيَّن بـ`?tenant=`. **اختبار الحراسة**: E2E يثبت أن السائق/الراكب (غير أدمن) يُرفضان من كل النقاط الإدارية (403). @@ -156,6 +159,7 @@ Shorebird يرقّع Dart فقط. بتوحيد Kotlin/الصلاحيات/NDK ت | Q3 | **السائق**: overlay العرض · قبول · widgets الرحلة · **متحكّم الموقع** (تسجيل 3ث/رفع دفعات/واعٍ للبطارية — نمط سيرو) · حيوية الوجه · الرصيد/الشحن. | | Q4 | **الخرائط المتقدّمة**: خريطة حرارية · خريطة تنبّؤية (من O1). | | Q5 | **البنية التحتية للتطبيق**: طبقة الشبكة (توقيع HMAC · x-device-id) · country pack ديناميكي · flavors · **طبقتا الإعداد وأعلام `const` حسب §1.5** · موديولات feature-first تُسجَّل حسب العلم. | +| Q6 | **نظام التصميم الثابت — [26-flutter-design-system](26-flutter-design-system.md)** (قرار المالك 2026-07-19): tokens · ثيم ليلي/نهاري من بذرة المستأجر · خطوط ثابتة (Inter + IBM Plex Sans Arabic مضمّنة محلياً) · اتجاه وخط يتبعان اللغة · `TripzScaffold` + عدة مكونات `core/ui/` · ARB للترجمة. **يُبنى قبل شاشات Q1–Q3** — ترتيب البناء في 26 §11. | --- diff --git a/docs/23-flutter-conventions.md b/docs/23-flutter-conventions.md index 16ef92b..127a61c 100644 --- a/docs/23-flutter-conventions.md +++ b/docs/23-flutter-conventions.md @@ -3,7 +3,7 @@ > **هذا المستند مُلزِم.** كل كود Dart في `apps/rider` و`apps/driver` يتبعه. أي انحراف يُصحَّح لا يُبرَّر. > اقرأه كاملاً قبل كتابة أول سطر في أي جلسة جديدة — هو العقد الذي يجعل جلستين مختلفتين تُنتجان نفس البنية. -آخر تحديث: 2026-07-18. ذو صلة: [22 §1.5](22-full-product-roadmap.md) (الطبقات) · [19](19-entitlements-licensing.md) (الاستحقاقات) · [[tripz-mobile-store-identity]]. +آخر تحديث: 2026-07-19. ذو صلة: [22 §1.5](22-full-product-roadmap.md) (الطبقات) · [19](19-entitlements-licensing.md) (الاستحقاقات) · **[26](26-flutter-design-system.md) (نظام التصميم الثابت — قانون الشكل المكمّل لهذا القانون)** · [[tripz-mobile-store-identity]]. --- @@ -28,7 +28,9 @@ apps//lib/ │ ├── config.dart # إعداد وقت التشغيل (--dart-define) │ ├── di.dart # get_it: تسجيل التبعيات │ ├── router.dart # go_router: كل المسارات -│ ├── theme.dart # الثيم من ألوان المستأجر +│ ├── design/ # نظام التصميم (docs/26): tokens · typography · tripz_colors · theme +│ ├── ui/ # عدة المكونات المشتركة (docs/26 §7): TripzScaffold · TripzButton … +│ ├── l10n/ # ملفات ARB عربي/إنجليزي — كل نص مرئي يمرّ هنا │ ├── api/ │ │ ├── api_client.dart # Dio + الاعتراضات (توكن · مستأجر · توقيع · جهاز) │ │ └── api_exception.dart @@ -141,7 +143,7 @@ if (Features.chat) routes.add(GoRoute(path: '/chat', builder: ...)); | `firebase.json` · `firebase_options.dart` · `google-services.json` | مشروع FCM القائم | | `shorebird.yaml` | **app_id منشور** — تغييره يقطع تحديثات OTA عن المستخدمين الحاليين | | `key/` | **شهادات توقيع** — فقدانها يعني تعذّر تحديث التطبيق المنشور نهائياً | -| الخطوط (mohanad · josefin · digit) | هوية بصرية قائمة | +| ~~الخطوط (mohanad · josefin · digit)~~ | **نُسخ (2026-07-19)**: الخطوط الجديدة الثابتة Inter + IBM Plex Sans Arabic — القرار وأسبابه في [26 §1](26-flutter-design-system.md). الملفات القديمة تبقى حتى يؤكد المالك حذفها | **معرّفات المتجر** في [[tripz-mobile-store-identity]] — تُنسخ ولا تُكتب من الذاكرة. ⚠️ معرّفا السائق متضاربان بين المنصّتين (`com.sefer_driver` مقابل `com.sefer.driver`) — **يؤكّدهما المالك قبل أي إصدار**. diff --git a/docs/26-flutter-design-system.md b/docs/26-flutter-design-system.md new file mode 100644 index 0000000..6aa0c4d --- /dev/null +++ b/docs/26-flutter-design-system.md @@ -0,0 +1,146 @@ +# 26 — نظام التصميم الثابت (الهوية البصرية لتطبيقات فلاتر) + +> **هذا المستند مُلزِم** ويكمّل [23-flutter-conventions](23-flutter-conventions.md): ذاك قانون *الكود*، وهذا قانون *الشكل*. +> الهدف: كل مستأجر يأخذ تطبيقاً يبدو مصمَّماً له، بينما هو نفس النظام حرفياً — المتغيّر لونه ولوغوه فقط. + +آخر تحديث: 2026-07-19. ذو صلة: [22 §1.5](22-full-product-roadmap.md) (الطبقات) · [06](06-tenant-model.md) (المستأجرون). + +--- + +## 0. العقد الحاكم: الثابت مقابل المتغيّر + +| **ثابت عبر كل المستأجرين والطبقات** | **متغيّر لكل مستأجر (من BuildConfig — يولّده N3)** | +|---|---| +| الخطوط (عربي + إنجليزي + أرقام) | اللون البذرة (seed) الأساسي والثانوي | +| المسافات والزوايا والظلال والحركة | اللوغو والأيقونة والـsplash | +| بنية الشاشات (TripzScaffold) وعدة المكونات | اسم التطبيق | +| سلوك الليلي/النهاري و RTL/LTR | — | +| فلسفة التصميم وأنماط الحالات | — | + +**القاعدة**: أي شيء غير الأعمدة الثلاثة (لون · لوغو · اسم) يتغيّر بين مستأجرين = خطأ يُصحَّح. +السبب تشغيلي قبل أن يكون جمالياً: نظام واحد ثابت = شاشة تُبنى مرة وتخدم كل المستأجرين، وShorebird يرقّعها للجميع دفعة واحدة. + +--- + +## 1. الخطوط — قرار 2026-07-19 + +المالك طلب «خط الآيفون». خط آبل هو **SF Pro** وهو مملوك لآبل ولا يجوز تضمينه قانونياً في تطبيق أندرويد. البديل المطابق المعتمد عالمياً من Google Fonts: + +| الدور | الخط | لماذا | +|---|---|---| +| إنجليزي + أرقام | **Inter** | أقرب مطابق حرّ لـSF Pro (نفس الروح الهندسية)، أوزان كاملة، أرقام tabular للعدّادات والأسعار | +| عربي | **IBM Plex Sans Arabic** | النظير العربي الأقرب لروح SF Arabic: حديث، محايد، أوزان كاملة، ممتاز في الأحجام الصغيرة | + +**قواعد لا تُخالَف:** +- الخطوط **تُضمَّن ملفاتٍ محلية** في `assets/fonts/` (نفس الملفات في الراكب والسائق). حزمة `google_fonts` بجلبها الشبكي **ممنوعة** — اتصال سوريا لا يُراهَن عليه، والشكل يجب أن يكون حتمياً من أول إقلاع. +- الأوزان المضمّنة: Regular · Medium · SemiBold · Bold لكل خط (٨ ملفات). لا أوزان أخرى. +- الأسعار والعدّادات والأرقام الحيّة تستعمل **Inter بأرقام tabular** (`FontFeature.tabularFigures`) كي لا «ترقص» الأعمدة عند تغيّر القيمة — هذا يُغني عن خط `digit` القديم. +- خطوط التطبيق القديم (`mohanad` · `josefin` · `digit`) **متقاعدة** من التصميم الجديد. ملفاتها تبقى في المستودع حتى يؤكد المالك حذفها. *(هذا يُحدّث docs/23 §7 الذي كان يعتبرها هوية قائمة.)* +- تبديل الخط مستقبلاً = تغيير سطرين في `typography.dart` — لا قرار مبعثر. + +--- + +## 2. الألوان والثيم + +- `ColorScheme.fromSeed` من لون المستأجر (`BuildConfig.primaryColor`) — نسختان light وdark تُشتقّان آلياً من نفس البذرة. **لا لون hex مكتوب في أي شاشة.** +- الأدوار الدلالية غير الموجودة في ColorScheme تعيش في `ThemeExtension` واحد اسمه `TripzColors`: `success` · `warning` · `danger` · `info` · `surfaceRaised` · ألوان طبقات الخريطة. الشاشة تقول `context.tripzColors.success` ولا تقرّر درجة اللون بنفسها. +- **الليلي ليس أسود صرفاً**: سطوح متدرّجة من الرمادي الداكن (elevation بالسطوع لا بالظل). الخريطة تبدّل ستايلها مع الثيم — خريطة نهارية في وضع ليلي تكسر الغرض. +- تباين النص على خلفيته **≥ 4.5:1** دائماً. البذرة مهما كانت فاتحة، `fromSeed` يضمن السطوح — والفحص جزء من قائمة تحقق الشاشة (§9). + +## 3. الرموز الثابتة (tokens) — `core/design/tokens.dart` + +كلها `const` في ملف واحد. الشاشة **لا تخترع رقماً**: + +| المجموعة | السلّم | +|---|---| +| المسافات | مضاعفات 4: `4 · 8 · 12 · 16 · 24 · 32 · 48` — حشوة الصفحة الافتراضية **16** | +| الزوايا | `8` (حقول/شارات) · `12` (أزرار/بطاقات) · `24` (bottom sheets) · دائري كامل (avatars) | +| الحركة | `120ms` (لمسة) · `240ms` (انتقال ضمن الشاشة) · `400ms` (sheet/صفحة) — منحنى `easeOutCubic` موحّد | +| الأيقونات | `20 · 24 · 32` — عائلة واحدة: Material Symbols Rounded | +| الارتفاع | زر CTA `52` · حقل إدخال `52` · هدف لمس أدنى `48` | + +## 4. الليلي/النهاري واللغة والاتجاه + +- **الثيم**: الافتراضي `ThemeMode.system`، والمستخدم يستطيع تثبيت light/dark من الإعدادات. الاختيار يُحفظ محلياً (SharedPreferences — ليس سراً) عبر `SettingsCubit` في `features/settings/`. +- **اللغة**: الافتراضي عربي، والإنجليزية مدعومة. الاختيار محفوظ بنفس الطريقة. +- **الاتجاه يتبع اللغة آلياً** — MaterialApp يفعلها وحده من الـlocale. ⚠️ الموجود الآن في `app.dart` يفرض `Directionality.rtl` فوق كل شيء — **يُحذف**: مع الإنجليزية يجب أن ينقلب التطبيق LTR كاملاً. +- **الخط يتبع اللغة**: الـTextTheme يُبنى حسب الـlocale — عربي ⇒ IBM Plex Sans Arabic والاحتياط Inter، إنجليزي ⇒ Inter والاحتياط Plex (كي لا تنكسر الكلمة العربية داخل جملة إنجليزية والعكس). +- **الترجمة**: ARB + `gen_l10n` القياسية. لا نص مرئي داخل widget — يمرّ بـ`context.l10n` (يفعّل قاعدة docs/23 §2 التي كانت بلا أداة). +- دالة بناء الثيم النهائية: `buildTheme(brightness, locale)` — المدخلان الوحيدان. + +## 5. الأرقام والتاريخ والعملة + +- **الأرقام لاتينية (1 2 3) دائماً** في اللغتين — عرف المنطقة في التطبيقات، وتناسق الأسعار والعدادات أهم من الأرقام الهندية. +- العملة ورمزها وموضعها من **country pack** المستأجر عبر `intl` — لا تنسيق يدوي بـ`toString`. +- التاريخ يُعرض بصيغة الـlocale، والزمن النسبي («قبل ٣ د») من طبقة الترجمة. + +## 6. مستوى السكافولد — `TripzScaffold` (نقطة التنظيم المركزية) + +كل شاشة تُبنى داخل `TripzScaffold` — **ممنوع `Scaffold` خام في `features/`**: + +```dart +TripzScaffold( + title: 'رحلاتي', // يمر بالترجمة فعلياً + status: state.status, // enum الـCubit كما هو + onRetry: cubit.load, // زر إعادة المحاولة في حالة الخطأ + empty: EmptyView(...), // حالة اللاشيء + bottomAction: TripzButton(...), // خانة CTA السفلية (52px) — keyboard-safe + child: ..., // المحتوى عند النجاح فقط +) +``` + +هو ما يجعل النظام **متوافقاً مع Cubit بالبنية لا بالاتفاق**: الـCubit يُصدر `status` من enum (قانون docs/23 §3)، والسكافولد يحوّله بنفسه إلى skeleton (تحميل) / ErrorView بزر إعادة (خطأ) / EmptyView (فراغ) / المحتوى (نجاح). الشاشة لا تكتب `if (loading)` أبداً — فتتوحّد حالات التطبيق كله من نقطة واحدة، وتغيير شكل «التحميل» لاحقاً سطرٌ واحد. + +كما يوحّد: SafeArea · حشوة الصفحة (16) · شكل AppBar (مسطّح، بلا ظل، عنوان يسار-حسب-الاتجاه) · سلوك الكيبورد (الـCTA يعلو فوقه). + +## 7. عدة المكونات — `core/ui/` + +الشاشات **تركّب من هذه العدة ولا تستعمل ويدجت Material خاماً بستايل يدوي**: + +| المكوّن | يغطي | +|---|---| +| `TripzButton` | primary (معبّأ 52px) · secondary (محدّد) · text — مع حالة تحميل داخلية | +| `TripzTextField` | إدخال موحّد: هاتف · OTP · بحث — رسالة الخطأ تحته بالعربية | +| `TripzSheet` | كل bottom sheet: مقبض علوي، زوايا 24، ارتفاعات محسوبة — **الحوار الافتراضي في التطبيق sheet لا dialog** | +| `TripzCard` | بطاقة الرحلة/السائق/المحفظة | +| `StatusBanner` | شريط حالة الرحلة الحي | +| `EmptyView` · `ErrorView` · `Skeleton` | الحالات الثلاث — تستهلكها `TripzScaffold` | + +ملف واحد = مكوّن واحد (قانون docs/23). مكوّن جديد يُضاف هنا أولاً ثم تستعمله الميزة — لا widget مشترك داخل `features/`. + +## 8. فلسفة التصميم (المرجع عند كل خلاف ذوقي) + +1. **الشاشة مهمة واحدة.** سؤال واحد للمستخدم، زر أساسي واحد في الأسفل. ما زاد نُقل لشاشة أو sheet. +2. **الوضوح قبل الزينة.** لا تدرّجات ولا ظلال ثقيلة ولا زخرفة. الجمال من الفراغ والمحاذاة والخط — النموذج الذهني: تطبيقات آبل الأصلية. +3. **منطقة الإبهام.** كل فعل أساسي في الثلث السفلي. أعلى الشاشة للمعلومة، أسفلها للفعل. +4. **الحالة معروضة دائماً**: skeleton لا spinner فارغ، خطأ بالعربية مع «أعد المحاولة»، فراغ برسالة تدلّ على الفعل التالي. شاشة بيضاء صامتة = باغ. +5. **الحركة وظيفة لا استعراض**: تشرح من-أين-إلى-أين ضمن أزمنة §3، وتُحترم إعدادات تقليل الحركة في النظام. + +## 9. قائمة تحقّق الشاشة (تُضاف لقائمة docs/23 §12) + +- [ ] داخل `TripzScaffold` وبحالات skeleton/error/empty تعمل فعلاً +- [ ] لا لون hex ولا رقم مسافة/زاوية خارج tokens — ولا `Scaffold`/Material خام بستايل يدوي +- [ ] تعمل بالعربية RTL وبالإنجليزية LTR (تُفحص باللغتين) +- [ ] تعمل light وdark (تُفحص بالوضعين) +- [ ] النصوص عبر `context.l10n`، الأرقام لاتينية، العملة من country pack +- [ ] أهداف اللمس ≥ 48 وتكبير النص حتى 1.3 لا يكسر التخطيط + +## 10. البنية في الكود (تحديث شجرة docs/23 §1) + +``` +lib/core/ +├── design/ +│ ├── tokens.dart # المسافات/الزوايا/الحركة — const +│ ├── typography.dart # TextTheme حسب الـlocale (Inter + Plex Arabic) +│ ├── tripz_colors.dart # ThemeExtension للأدوار الدلالية +│ └── theme.dart # buildTheme(brightness, locale) — يستبدل theme.dart الحالي +├── ui/ # عدة المكونات (§7) +└── l10n/ # ARB عربي/إنجليزي +features/settings/ # SettingsCubit: الثيم + اللغة (محفوظان محلياً) +``` + +## 11. ترتيب البناء (يسبق شاشات المجموعة Q) + +1. **Q5-أ**: الخطوط (تنزيل الأوزان الثمانية للتطبيقين) · tokens · typography · TripzColors · buildTheme · حذف فرض RTL من `app.dart` · SettingsCubit. +2. **Q5-ب**: `TripzScaffold` + عدة المكونات + ARB — ثم **تُعاد صياغة** شاشات auth/home القائمة عليها كإثبات. +3. بعدها فقط تُبنى شاشات Q1/Q2/Q3 — كل شاشة جديدة تولد ملتزمة من أول سطر.