feat: implement rewards module with referral/coupon systems, update tariff seeding, and add trip distance service for improved billing accuracy.

This commit is contained in:
Hamza-Ayed
2026-07-19 00:20:37 +03:00
parent 22d18f32fe
commit 92f1eeb48c
39 changed files with 3612 additions and 208 deletions
-165
View File
@@ -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.');
+2
View File
@@ -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,
+17 -17
View File
@@ -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[] = [
+6
View File
@@ -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 ?? '',
@@ -0,0 +1,169 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* الإحالات والكوبونات (المجموعة L — قرار المالك 2026-07-18).
*
* مكافأة الراكب كوبون لا رصيد محفظة: محفظة الراكب لا تُسحب أبداً (قانون
* ثابت)، فإنزال مكافأة تسويقية فيها يخلق التزاماً نقدياً بلا مقابل.
* والحدود هنا مفروضة على القاعدة لا بفحص تطبيقي وحده — الفحص التطبيقي
* يسقط تحت التزامن، والقيد لا يسقط.
*/
export class ReferralsAndCoupons1721950000000 implements MigrationInterface {
public async up(q: QueryRunner): Promise<void> {
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<void> {
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`);
}
}
@@ -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<void> {
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<void> {
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
`);
}
}
@@ -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}`);
+335
View File
@@ -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;
+3 -1
View File
@@ -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')
+2
View File
@@ -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],
+19 -1
View File
@@ -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);
}
@@ -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<boolean> {
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).
* **لا يفشل أبداً** — ولو صار الرصيد سالباً. الرحلة تمّت فالعمولة استُحقّت؛
@@ -15,6 +15,7 @@ export type CreditTxnType =
| 'topup' // شحن مدفوع — إيراد
| 'signup_bonus' // مكافأة تسجيل — تكلفة تجنيد
| 'promo_bonus' // حافز باقة («اشحن 50 خذ 55») — تكلفة تسويق
| 'referral_bonus'// مكافأة إحالة — تكلفة تجنيد، منفصلة عن حافز الباقة
| 'commission' // خصم عمولة رحلة
| 'adjustment'; // تسوية يدوية من الأدمن
@@ -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);
});
});
@@ -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<Coupon, 'discount_type' | 'discount_value' | 'max_discount'>,
policy: Pick<RewardsPolicy, 'maxDiscount'>,
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<Coupon>,
@InjectRepository(CouponRedemption)
private readonly redemptions: Repository<CouponRedemption>,
private readonly tenants: TenantsService,
) {}
/** سياسة المكافآت لعملةٍ ما — عبر كاش المستأجر القائم (نمط `credit.policy`). */
async policy(tenantId: string, currency = 'JOD'): Promise<RewardsPolicy> {
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<CouponEvaluation> {
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<number> {
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<Coupon | null> {
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<Coupon> {
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<Coupon[]> {
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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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<string, any> = {}) =>
({ 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<string, any>, 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);
});
});
@@ -0,0 +1,275 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Referral, ReferralParty } from './entities/referral.entity';
import { ReferralCode } from './entities/referral-code.entity';
import { CouponsService, randomCode } from './coupons.service';
import { resolveRewardsPolicy, RewardsPolicy } from './rewards-policy';
import { TenantsService } from '../tenants/tenants.service';
import { DriverCreditService } from '../credit/driver-credit.service';
import { DriversService } from '../drivers/drivers.service';
/**
* نظام الإحالة بالاتجاهات الأربعة (المجموعة L).
*
* شكل المكافأة يتبع **دور المستفيد** لا اتجاه الإحالة:
* - سائق → رصيد تشغيلي (`referral_bonus`).
* - راكب → كوبون خصم مسقوف. **لا رصيد محفظة**: محفظة الراكب لا تُسحب
* (قانون ثابت)، فمكافأة تسويقية فيها تصير التزاماً نقدياً بلا مقابل.
*
* والاستحقاق **بعد رحلة مكتملة** لا عند التسجيل — بدون هذا الشرط خمسون
* رقماً وهمياً تساوي خمسين مكافأة، وهو بالضبط ما يأكل ميزانية التسويق.
*/
@Injectable()
export class ReferralsService {
private readonly logger = new Logger('Referrals');
constructor(
@InjectRepository(Referral) private readonly referrals: Repository<Referral>,
@InjectRepository(ReferralCode) private readonly codes: Repository<ReferralCode>,
private readonly coupons: CouponsService,
private readonly credit: DriverCreditService,
private readonly drivers: DriversService,
private readonly tenants: TenantsService,
) {}
async policy(tenantId: string, currency = 'JOD'): Promise<RewardsPolicy> {
const t = await this.tenants.resolve(tenantId);
return resolveRewardsPolicy(t?.settings, currency);
}
/** كود الدعوة الشخصي — يُولَّد عند أول طلب ويثبت بعدها. */
async myCode(
tenantId: string,
userId: string,
role: ReferralParty,
): Promise<ReferralCode> {
const existing = await this.codes.findOne({
where: { tenant_id: tenantId, user_id: userId },
});
if (existing) return existing;
for (let attempt = 0; attempt < 5; attempt++) {
try {
return await this.codes.save(
this.codes.create({
tenant_id: tenantId,
user_id: userId,
code: randomCode(6),
role,
}),
);
} catch (e: any) {
if (e?.code !== '23505' && e?.driverError?.code !== '23505') throw e;
// إمّا تصادم كود (نعيد التوليد) أو نداءان متزامنان لنفس المستخدم
// (الفريد على user_id) — والثاني يعني أن الكود صار موجوداً.
const mine = await this.codes.findOne({
where: { tenant_id: tenantId, user_id: userId },
});
if (mine) return mine;
}
}
throw new BadRequestException('could not allocate referral code');
}
/**
* يسجّل إحالة عند تسجيل مستخدم جديد بكود دعوة. `pending` — لا تُصرف الآن.
*
* **لا يرمي عند كود خاطئ**: هذا يُنادى داخل مسار التسجيل، وفشل الإحالة
* يجب ألّا يمنع مستخدماً من دخول التطبيق. يعيد `null` ويسجّل السبب.
*/
async register(
tenantId: string,
refereeUserId: string,
refereeRole: ReferralParty,
code: string,
currency = 'JOD',
): Promise<Referral | null> {
const normalized = CouponsService.normalize(code);
if (!normalized) return null;
const owner = await this.codes.findOne({
where: { tenant_id: tenantId, code: normalized, status: 'active' },
});
if (!owner) {
this.logger.warn(`unknown referral code ${normalized}`);
return null;
}
if (owner.user_id === refereeUserId) {
this.logger.warn(`self-referral blocked for ${refereeUserId}`);
return null;
}
try {
return await this.referrals.save(
this.referrals.create({
tenant_id: tenantId,
referrer_user_id: owner.user_id,
referee_user_id: refereeUserId,
referrer_role: owner.role,
referee_role: refereeRole,
code: normalized,
status: 'pending',
currency,
}),
);
} catch (e: any) {
// الفريد على referee: هذا المستخدم مُحال سلفاً — يُحال مرة واحدة فقط.
if (e?.code === '23505' || e?.driverError?.code === '23505') {
this.logger.warn(`user ${refereeUserId} already referred — ignoring ${normalized}`);
return null;
}
throw e;
}
}
/**
* يُنادى عند إتمام رحلة: يزيد عدّاد المُحال، ويؤهّله إن بلغ الشرط.
*
* الترقية `pending → qualified` بـUPDATE شرطي على الحالة، فلا ترقية
* مزدوجة لو تزامن نداءان (رحلتان تُنهيان في اللحظة نفسها).
*/
async onTripCompleted(
tenantId: string,
riderOrDriverUserId: string,
tripId: string,
): Promise<void> {
const referral = await this.referrals.findOne({
where: { tenant_id: tenantId, referee_user_id: riderOrDriverUserId, status: 'pending' },
});
if (!referral) return;
const { qualifyingTrips } = await this.policy(tenantId, referral.currency);
// خطوتان لا عبارة واحدة بـCASE: حساب العتبة **في TypeScript** لا داخل
// SQL. المحاولة الأولى وضعت `CASE WHEN qualifying_trips + 1 >= n` فجمع
// المحرّك العمود نصّاً ('0' + 1 = '01') ولم تتحقق الشرطية أبداً —
// فبقيت كل إحالة معلّقة بلا مكافأة، وهو فشل صامت لا يُكتشف إلا بشكوى.
// والتزامن محفوظ: كلتا العبارتين مشروطتان بالحالة، فرحلتان تنتهيان معاً
// لا تؤهّلان مرتين.
const bumped = await this.referrals
.createQueryBuilder()
.update(Referral)
.set({ qualifying_trips: () => 'qualifying_trips + 1' })
.where('id = :id AND tenant_id = :tenant AND status = :pending', {
id: referral.id,
tenant: tenantId,
pending: 'pending',
})
.returning(['qualifying_trips'])
.execute();
if (!bumped.affected) return;
const count = Number(bumped.raw?.[0]?.qualifying_trips ?? NaN);
if (!Number.isFinite(count) || count < qualifyingTrips) return;
const promoted = await this.referrals
.createQueryBuilder()
.update(Referral)
.set({ status: 'qualified', qualified_trip_id: tripId, qualified_at: new Date() })
.where('id = :id AND tenant_id = :tenant AND status = :pending', {
id: referral.id,
tenant: tenantId,
pending: 'pending',
})
.execute();
if (!promoted.affected) return; // سبقنا نداء متزامن — هو من سيصرف
// الصرف الفوري محاولةٌ أولى؛ الماسح شبكة الأمان لو فشلت هنا.
const fresh = await this.referrals.findOne({ where: { id: referral.id } });
if (fresh?.status === 'qualified') {
await this.payout(fresh).catch((e) =>
this.logger.error(`immediate payout failed for ${fresh.id}: ${e?.message}`),
);
}
}
/**
* يصرف مكافأتَي الطرفين ثم يعلّم الإحالة مدفوعة.
*
* الترتيب مقصود: **الصرف أولاً ثم التعليم**. لو عُلّمت مدفوعة قبل الصرف
* وسقطت العملية بينهما، ضاعت المكافأة بلا أثر ولا إعادة محاولة. والعكس
* (صرف ثم سقوط قبل التعليم) يُصلحه الماسح، والحارس الفريد على الدفتر
* يمنع الصرف مرتين.
*/
async payout(referral: Referral): Promise<void> {
const policy = await this.policy(referral.tenant_id, referral.currency);
await this.reward(referral, referral.referrer_user_id, policy, 'referrer');
await this.reward(referral, referral.referee_user_id, policy, 'referee');
await this.referrals
.createQueryBuilder()
.update(Referral)
.set({ status: 'paid', paid_at: new Date() })
.where('id = :id AND status = :qualified', { id: referral.id, qualified: 'qualified' })
.execute();
}
/** الإحالات المؤهَّلة غير المصروفة — مدخل الماسح. */
findQualified(limit = 50): Promise<Referral[]> {
return this.referrals.find({
where: { status: 'qualified' },
order: { qualified_at: 'ASC' },
take: limit,
});
}
/** ملخّص «دعواتي» — للتطبيق. */
async summary(tenantId: string, userId: string, role: ReferralParty) {
const code = await this.myCode(tenantId, userId, role);
const mine = await this.referrals.find({
where: { tenant_id: tenantId, referrer_user_id: userId },
order: { created_at: 'DESC' },
take: 100,
});
return {
code: code.code,
total: mine.length,
pending: mine.filter((r) => r.status === 'pending').length,
paid: mine.filter((r) => r.status === 'paid').length,
};
}
// ---- داخلي ----
/**
* يوجّه المكافأة حسب دور المستفيد: سائق ← رصيد · راكب ← كوبون.
*
* الدور يُحسم **وقت الصرف** من وجود صفّ سائق، لا من `referee_role` المخزَّن
* وقت التسجيل: من سجّل بكود دعوة ثم تقدّم سائقاً يستحقّ رصيداً تشغيلياً لا
* كوبون خصم. الدور المخزَّن يبقى للتقارير («من أين جاء سائقونا؟»).
*/
private async reward(
referral: Referral,
userId: string,
policy: RewardsPolicy,
side: 'referrer' | 'referee',
): Promise<void> {
const driver = await this.drivers.findByUser(referral.tenant_id, userId);
if (driver) {
await this.credit.grantReferralBonus(
referral.tenant_id,
driver.id,
policy.driverReferralBonus,
// الطرفان في إحالة واحدة، فالمعرّف وحده يجعل قيديهما متطابقين تحت
// الفهرس الفريد فيُرفض ثانيهما — اللاحقة تفصلهما.
`${referral.id}:${side}`,
referral.currency,
);
return;
}
if (!(policy.riderReferralCoupon > 0)) return;
await this.coupons.grant({
tenantId: referral.tenant_id,
userId,
amount: policy.riderReferralCoupon,
currency: referral.currency,
source: 'referral',
validDays: policy.referralCouponDays,
codePrefix: 'REF',
ref: `${referral.id}:${side}`,
});
}
}
@@ -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<string, number> = {
JOD: 1.375,
SYP: 190, // بالعملة السورية الجديدة (187.5 مدوَّرة لخانة الليرة)
EGP: 25,
};
/** مكافأة إحالة السائق — تنزل رصيداً تشغيلياً (`referral_bonus`). */
export const DEFAULT_DRIVER_REFERRAL_BONUS: Record<string, number> = {
JOD: 1,
SYP: 140,
EGP: 18,
};
/** مكافأة إحالة الراكب — قيمة الكوبون الممنوح، وتبقى محكومة بالسقف أعلاه. */
export const DEFAULT_RIDER_REFERRAL_COUPON: Record<string, number> = {
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<string, any> | 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;
}
@@ -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<string, number>;
driver_referral_bonus?: Record<string, number>;
rider_referral_coupon?: Record<string, number>;
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';
}
@@ -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 {}
@@ -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<number>('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<void> {
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;
}
}
}
@@ -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<string, number> = {
[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;
}
@@ -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. */
@@ -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);
});
});
+67 -6
View File
@@ -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);
@@ -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;
@@ -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<any>;
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);
});
});
@@ -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);
});
});
@@ -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<DriverTrack>,
) {}
/**
* يجمع مقاطع 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<Pick<DriverTrack, 'lat' | 'lng' | 'recorded_at'>>,
): 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<BillableDistance> {
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;
}
}
+6 -2
View File
@@ -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 {}
+165 -13
View File
@@ -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<Trip | null> {
@@ -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<Trip> | 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}`);