chore: أرشفة باك إند NestJS إلى backend-archive

قرار المالك 2026-07-27: الباك إند المعتمد صار باك إند سيرو PHP، ويُنقل
إلى هذا المستودع كنسخة جديدة باسم «انطلق» على api.intaleqapp.com.

backend/ → backend-archive/ (305 ملفاً، إعادة تسمية بلا تعديل محتوى)
+ README-ARCHIVE.md يوضّح سبب الأرشفة وسبب عدم الحذف: scripts/e2e-test.mjs
  هو أدق توثيق سلوكي لدورة الرحلة كاملة، ويبقى مرجعاً عند بناء الوحدات
  الناقصة في باك إند PHP (المحفظتان · الاستحقاقات · محرّك التسعير · الوحدات).

مسارات backend/ في docs/*.md لم تُعدَّل عمداً — سجلّ تاريخي.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hamza-Ayed
2026-07-27 04:54:35 +03:00
co-authored by Claude Opus 5
parent 75b80bd299
commit fff76c949f
307 changed files with 47 additions and 0 deletions
+136
View File
@@ -0,0 +1,136 @@
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
import configuration from './config/configuration';
import { TenantMiddleware } from './common/tenant/tenant.middleware';
import { RedisModule } from './common/redis/redis.module';
import { I18nModule } from './common/i18n/i18n.module';
import { CacheModule } from './common/cache/cache.module';
import { EntitlementsModule } from './common/entitlements/entitlements.module';
import { PlatformModule } from './common/platform/platform.module';
import { AuditModule } from './common/audit/audit.module';
import { SigningModule } from './common/signing/signing.module';
import { PhoneModule } from './common/phone/phone.module';
import { DeviceModule } from './common/device/device.module';
import { SeedModule } from './common/seed/seed.module';
import { HealthModule } from './modules/health/health.module';
import { TenantsModule } from './modules/tenants/tenants.module';
import { UsersModule } from './modules/users/users.module';
import { AuthModule } from './modules/auth/auth.module';
import { DriversModule } from './modules/drivers/drivers.module';
import { MatchingModule } from './modules/matching/matching.module';
import { LocationsModule } from './modules/locations/locations.module';
import { TariffModule } from './modules/tariff/tariff.module';
import { MapsModule } from './modules/maps/maps.module';
import { TripsModule } from './modules/trips/trips.module';
import { RealtimeModule } from './realtime/realtime.module';
import { FraudModule } from './modules/fraud/fraud.module';
import { ChatModule } from './modules/chat/chat.module';
import { RatingsModule } from './modules/ratings/ratings.module';
import { NabehModule } from './integrations/nabeh/nabeh.module';
import { OtpModule } from './integrations/otp/otp.module';
import { RideTypesModule } from './modules/ride-types/ride-types.module';
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';
import { DocumentsModule } from './modules/documents/documents.module';
import { VehiclesModule } from './modules/vehicles/vehicles.module';
import { GeminiModule } from './integrations/gemini/gemini.module';
import { BillingModule } from './modules/billing/billing.module';
import { GeofenceModule } from './modules/geofence/geofence.module';
import { CatalogModule } from './modules/catalog/catalog.module';
import { CronModule } from './common/cron/cron.module';
import { MarketingModule } from './modules/marketing/marketing.module';
import { TransitModule } from './modules/transit/transit.module';
import { BotModule } from './modules/bots/bot.module';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true, load: [configuration] }),
TypeOrmModule.forRootAsync({
inject: [ConfigService],
useFactory: (cfg: ConfigService) => ({
type: 'postgres',
host: cfg.get<string>('db.host'),
port: cfg.get<number>('db.port'),
database: cfg.get<string>('db.name'),
username: cfg.get<string>('db.user'),
password: cfg.get<string>('db.password'),
// بركة اتصالات أكبر — يرفع سقف التزامن تحت الحمل العالي
extra: { max: parseInt(process.env.DB_POOL_MAX ?? '30', 10) },
// بادئة الجداول (tripz_) لعزل السيرفر المشترك — راجع docs/14
entityPrefix: cfg.get<string>('db.tablePrefix'),
synchronize: cfg.get<boolean>('db.synchronize'),
autoLoadEntities: true,
}),
}),
ThrottlerModule.forRoot([{ ttl: 60000, limit: 120 }]),
RedisModule, // عالمي — عميل Redis للمطابقة و OTP
I18nModule, // عالمي — ترجمة نصوص الإشعارات
CacheModule, // عالمي — كاش-جانبي فوق Redis (خط أول قبل القاعدة)
EntitlementsModule, // عالمي — استحقاقات الاشتراك + FeatureGuard
PlatformModule, // عالمي — حارس السوبر-أدمن (x-platform-secret)
AuditModule, // عالمي — سجل التدقيق المالي
SigningModule, // عالمي — توقيع HMAC للعمليات المالية
PhoneModule, // عالمي — تطبيع أرقام الهاتف لكل دولة
DeviceModule, // عالمي — ربط الجلسة بالجهاز
NabehModule, // عالمي — إرسال OTP واتساب (سوريا/الأردن)
OtpModule, // عالمي — توجيه OTP متعدد المزوّدين حسب الدولة + failover
NotificationsModule, // عالمي — FCM
StorageModule, // عالمي — تخزين ملفات الوثائق
GeminiModule, // عالمي — رؤية AI (وثائق + وجه)
GeofenceModule,
HealthModule,
TenantsModule,
UsersModule,
AuthModule,
MapsModule,
MatchingModule,
LocationsModule,
TariffModule,
RideTypesModule,
DriversModule,
RealtimeModule,
FraudModule,
WalletModule,
TenantWalletModule, // دفترا المستأجر: إيراد وأمانات (docs/24)
CreditModule,
RewardsModule, // الإحالات والكوبونات (المجموعة L)
PaymentsModule,
BillingModule,
TripsModule,
DispatchModule,
ChatModule,
RatingsModule,
DocumentsModule,
VehiclesModule,
SeedModule,
CatalogModule,
CronModule, // O5 — المهام الدورية الموحّدة على BullMQ
MarketingModule, // O3 — محرك التسويق التلقائي
TransitModule, // O2 — نظام المواصلات
BotModule, // O4 — بوتات التواصل الاجتماعي
],
providers: [
// ThrottlerModule.forRoot() وحده لا يفعل شيئاً — كان مسجَّلاً منذ البداية
// بلا أي حارس يطبّقه، فتحديد 120/60s لم يكن ساري المفعول إطلاقاً على أي
// نقطة. APP_GUARD يُفعّله عالمياً على كل نقطة تلقائياً (docs/17 — D4).
{ provide: APP_GUARD, useClass: ThrottlerGuard },
],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(TenantMiddleware).forRoutes('*');
}
}
@@ -0,0 +1,56 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
} from 'typeorm';
/**
* سجل التدقيق المالي (docs/17 — I7؛ مكافئ `admin_audit_log` عند سيرو).
* الجدول: tripz_audit_log. **append-only** — لا تحديث ولا حذف.
*
* يجيب عن سؤال واحد عند كل نزاع: **من فعل ماذا ومتى ومن أين؟**
*/
@Entity('audit_log')
@Index(['tenant_id', 'created_at'])
@Index(['tenant_id', 'subject_type', 'subject_id'])
export class AuditLog {
@PrimaryGeneratedColumn('increment')
id: string;
@Column({ type: 'uuid' })
tenant_id: string;
/** من نفّذ الفعل — قد يكون السائق نفسه أو أدمن أو `null` للنظام. */
@Column({ type: 'uuid', nullable: true })
actor_user_id: string | null;
@Column({ type: 'varchar', nullable: true })
actor_role: string | null;
/** payout.request · payout.confirm · payout.complete · payout.fail · credit.topup … */
@Column()
action: string;
@Column({ type: 'varchar', nullable: true })
subject_type: string | null; // payout | credit | wallet
@Column({ type: 'varchar', nullable: true })
subject_id: string | null;
@Column({ type: 'numeric', precision: 12, scale: 3, nullable: true })
amount: number | null;
@Column({ type: 'varchar', nullable: true })
currency: string | null;
@Column({ type: 'jsonb', default: {} })
meta: Record<string, any>;
@Column({ type: 'varchar', nullable: true })
ip: string | null;
@CreateDateColumn()
created_at: Date;
}
@@ -0,0 +1,12 @@
import { Global, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuditLog } from './audit-log.entity';
import { AuditService } from './audit.service';
@Global()
@Module({
imports: [TypeOrmModule.forFeature([AuditLog])],
providers: [AuditService],
exports: [AuditService],
})
export class AuditModule {}
@@ -0,0 +1,58 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AuditLog } from './audit-log.entity';
export interface AuditEntry {
tenantId: string;
action: string;
actorUserId?: string | null;
actorRole?: string | null;
subjectType?: string;
subjectId?: string;
amount?: number | null;
currency?: string | null;
meta?: Record<string, any>;
ip?: string | null;
}
/** سجل التدقيق المالي (docs/17 — I7). */
@Injectable()
export class AuditService {
private readonly logger = new Logger('Audit');
constructor(@InjectRepository(AuditLog) private readonly repo: Repository<AuditLog>) {}
/**
* يسجّل فعلاً مالياً.
*
* **لا يرمي أبداً**: فشل التدقيق يجب ألّا يُسقط عمليةً ماليةً نجحت — وإلا
* صار السجل نقطة فشل بدل أن يكون شبكة أمان. الفشل يُسجَّل في اللوغ ليُرى.
*/
async record(entry: AuditEntry): Promise<void> {
try {
await this.repo.insert({
tenant_id: entry.tenantId,
actor_user_id: entry.actorUserId ?? null,
actor_role: entry.actorRole ?? null,
action: entry.action,
subject_type: entry.subjectType ?? null,
subject_id: entry.subjectId ?? null,
amount: entry.amount ?? null,
currency: entry.currency ?? null,
meta: entry.meta ?? {},
ip: entry.ip ?? null,
});
} catch (e: any) {
this.logger.error(`audit write failed [${entry.action}]: ${e?.message}`);
}
}
/** سجلّ موضوع بعينه — للتحقيق في نزاع. */
forSubject(tenantId: string, subjectType: string, subjectId: string) {
return this.repo.find({
where: { tenant_id: tenantId, subject_type: subjectType, subject_id: subjectId },
order: { created_at: 'ASC' },
});
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { CacheService } from './cache.service';
@Global()
@Module({
providers: [CacheService],
exports: [CacheService],
})
export class CacheModule {}
+71
View File
@@ -0,0 +1,71 @@
import RedisMock from 'ioredis-mock';
import { CacheService, CacheKeys } from './cache.service';
describe('CacheService', () => {
let redis: any;
let cache: CacheService;
// ioredis-mock يشارك مخزناً واحداً بين النسخ ذات الإعدادات نفسها —
// بلا flushall تتسرّب مفاتيح اختبار إلى الذي يليه.
beforeEach(async () => {
redis = new RedisMock({ keyPrefix: 'tripz:' });
await redis.flushall();
cache = new CacheService(redis);
});
it('يخزّن ويقرأ قيمة مركّبة', async () => {
await cache.set('k', { a: 1, b: ['x'] }, 60);
expect(await cache.get<{ a: number; b: string[] }>('k')).toEqual({ a: 1, b: ['x'] });
});
it('يرجع null لمفتاح غائب', async () => {
expect(await cache.get('nope')).toBeNull();
});
it('wrap: يحمّل من المصدر مرة واحدة ثم يخدم من الكاش', async () => {
const loader = jest.fn().mockResolvedValue({ id: 't1' });
expect(await cache.wrap('k', 60, loader)).toEqual({ id: 't1' });
expect(await cache.wrap('k', 60, loader)).toEqual({ id: 't1' });
expect(loader).toHaveBeenCalledTimes(1);
});
it('wrap: لا يخزّن null — الغياب المؤقّت لا يُثبَّت', async () => {
const loader = jest.fn().mockResolvedValue(null);
expect(await cache.wrap('k', 60, loader)).toBeNull();
expect(await cache.wrap('k', 60, loader)).toBeNull();
expect(loader).toHaveBeenCalledTimes(2);
});
it('del يُبطل المفتاح فيعود المصدر', async () => {
const loader = jest.fn().mockResolvedValue('v1');
await cache.wrap('k', 60, loader);
await cache.del('k');
await cache.wrap('k', 60, loader);
expect(loader).toHaveBeenCalledTimes(2);
});
it('mget يقرأ عدة مفاتيح ويعطي null للغائب', async () => {
await cache.set('a', 1, 60);
await cache.set('c', 3, 60);
expect(await cache.mget<number>(['a', 'b', 'c'])).toEqual([1, null, 3]);
});
it('فشل Redis لا يُسقط الطلب — يرجع للمصدر', async () => {
const broken = {
get: jest.fn().mockRejectedValue(new Error('redis down')),
set: jest.fn().mockRejectedValue(new Error('redis down')),
} as any;
const c = new CacheService(broken);
const loader = jest.fn().mockResolvedValue('from-db');
expect(await c.wrap('k', 60, loader)).toBe('from-db');
expect(loader).toHaveBeenCalledTimes(1);
});
it('المفاتيح معزولة بالمستأجر', () => {
expect(CacheKeys.userLang('t1', 'u1')).not.toBe(CacheKeys.userLang('t2', 'u1'));
expect(CacheKeys.tariff('t1', 'amman', 'economy')).toBe('tariff:t1:amman:economy');
});
});
+103
View File
@@ -0,0 +1,103 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import Redis from 'ioredis';
import { REDIS } from '../redis/redis.module';
/** أعمار الكاش بالثواني — مجمّعة هنا ليسهل ضبطها (docs/17 — G). */
export const TTL = {
/** المستأجر يتغيّر نادراً جداً. */
tenant: 3600,
/** لغة المستخدم — يرفعها فلاتر عند الفتح. */
userLang: 24 * 3600,
/** توكنات FCM للجهاز. */
deviceTokens: 6 * 3600,
/** التعرفة و ride-types — شبه ثابتة، وتُبطَل صراحةً عند تعديل الأدمن. */
catalog: 900,
/** نتائج الخرائط — عناوين ومسارات لا تتغيّر عملياً. */
maps: 24 * 3600,
} as const;
/**
* كاش-جانبي (cache-aside) فوق Redis — «الريدز خط أول، القاعدة خط احتياط»
* (docs/17 — G).
*
* مبدأ صارم: **فشل Redis لا يُسقط الطلب**. أي خطأ هنا يُسجَّل ويُرجَع للقاعدة،
* لأن الكاش تحسين أداء لا مصدر حقيقة.
*/
@Injectable()
export class CacheService {
private readonly logger = new Logger('Cache');
constructor(@Inject(REDIS) private readonly redis: Redis) {}
async get<T>(key: string): Promise<T | null> {
try {
const raw = await this.redis.get(key);
return raw == null ? null : (JSON.parse(raw) as T);
} catch (e: any) {
this.logger.warn(`get ${key} failed: ${e?.message}`);
return null;
}
}
async set(key: string, value: unknown, ttlSec: number): Promise<void> {
try {
await this.redis.set(key, JSON.stringify(value), 'EX', ttlSec);
} catch (e: any) {
this.logger.warn(`set ${key} failed: ${e?.message}`);
}
}
async del(...keys: string[]): Promise<void> {
if (keys.length === 0) return;
try {
await this.redis.del(...keys);
} catch (e: any) {
this.logger.warn(`del failed: ${e?.message}`);
}
}
/**
* يقرأ من الكاش، وعند الغياب يحمّل من المصدر ويخزّن.
* `null` من المصدر لا يُخزَّن — لئلا يُثبَّت غياب مؤقّت لمدة TTL كاملة.
*/
async wrap<T>(key: string, ttlSec: number, loader: () => Promise<T | null>): Promise<T | null> {
const hit = await this.get<T>(key);
if (hit !== null) return hit;
const fresh = await loader();
if (fresh !== null && fresh !== undefined) await this.set(key, fresh, ttlSec);
return fresh;
}
/** قراءة عدة مفاتيح بجولة واحدة — للبث الجماعي (docs/17 — G3). */
async mget<T>(keys: string[]): Promise<(T | null)[]> {
if (keys.length === 0) return [];
try {
const raws = await this.redis.mget(...keys);
return raws.map((raw) => {
if (raw == null) return null;
try {
return JSON.parse(raw) as T;
} catch {
return null;
}
});
} catch (e: any) {
this.logger.warn(`mget failed: ${e?.message}`);
return keys.map(() => null);
}
}
}
/** مفاتيح الكاش — مجمّعة لتفادي التضارب ولتسهيل الإبطال. */
export const CacheKeys = {
tenant: (slugOrId: string) => `tenant:${slugOrId}`,
userLang: (tenantId: string, userId: string) => `user:lang:${tenantId}:${userId}`,
deviceTokens: (tenantId: string, userId: string) => `user:fcm:${tenantId}:${userId}`,
tariff: (tenantId: string, city: string, serviceClass: string) =>
`tariff:${tenantId}:${city}:${serviceClass}`,
rideTypes: (tenantId: string) => `ridetypes:${tenantId}`,
mapsRoute: (from: string, to: string) => `maps:route:${from}:${to}`,
mapsReverse: (lat: string, lng: string) => `maps:rev:${lat}:${lng}`,
mapsGeocode: (country: string, q: string) => `maps:geo:${country}:${q}`,
};
@@ -0,0 +1,40 @@
import { Global, Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Queue } from 'bullmq';
/**
* اسم الطابور الوحيد — كل المهام الدورية تدخل هنا.
* البادئة `tripz_` تأتي من `QUEUE_PREFIX` في الإعدادات.
*/
export const CRON_QUEUE = 'tripz_cron';
/**
* وحدة BullMQ عالمية — تنشئ طابوراً واحداً لكل المهام الدورية.
*
* النمط: `@Global` مثل `RedisModule` — تُصدَّر وتُستهلك في أي وحدة.
* الطابور يقرأ اتصال Redis من `REDIS_CLIENT` (نفس DB 3 + البادئة tripz:).
*/
@Global()
@Module({
providers: [
{
provide: CRON_QUEUE,
inject: [ConfigService],
useFactory: (cfg: ConfigService) =>
new Queue(CRON_QUEUE, {
connection: {
host: cfg.get<string>('redis.host'),
port: cfg.get<number>('redis.port'),
db: cfg.get<number>('redis.db'),
},
prefix: cfg.get<string>('queue.prefix'),
defaultJobOptions: {
removeOnComplete: 100,
removeOnFail: 50,
},
}),
},
],
exports: [CRON_QUEUE],
})
export class BullModule {}
@@ -0,0 +1,80 @@
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
import { ApiSecurity, ApiTags } from '@nestjs/swagger';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { PlatformGuard } from '../platform/platform.guard';
import { JobExecution } from './job-execution.entity';
import { CronOrchestratorService } from './cron-orchestrator.service';
import { CRON_SCHEDULES, CRON_JOB_LABELS, CronJobName } from './cron-registry';
/**
* لوحة المهام الدورية — السوبر-أدن (O5).
*
* تُظهر: آخر نجاح/فشل لكل مهمة + مدة التنفيذ + السجل التاريخي.
* لا مهمة صامتة — كل ما في الجدول هو ما حدث فعلاً.
*/
@ApiTags('cron')
@Controller()
export class CronAdminController {
constructor(
private readonly orchestrator: CronOrchestratorService,
@InjectRepository(JobExecution)
private readonly execRepo: Repository<JobExecution>,
) {}
/** إحصائيات كل المهام + آخر تنفيذ. */
@ApiSecurity('x-platform-secret')
@UseGuards(PlatformGuard)
@Get('admin/cron/stats')
stats() {
return this.orchestrator.getStats();
}
/** سجل التنفيذ التاريخي (مع التصفية حسب اسم المهمة). */
@ApiSecurity('x-platform-secret')
@UseGuards(PlatformGuard)
@Get('admin/cron/executions')
async listExecutions(
@Query('job_name') jobName?: string,
@Query('limit') limit?: string,
) {
const take = Math.min(parseInt(limit ?? '50', 10) || 50, 200);
const where: any = {};
if (jobName) where.job_name = jobName;
return this.execRepo.find({ where, order: { started_at: 'DESC' }, take });
}
/** آخر 10 تنفيذات لكل مهمة (ملخص سريع). */
@ApiSecurity('x-platform-secret')
@UseGuards(PlatformGuard)
@Get('admin/cron/summary')
async summary() {
const jobs = Object.keys(CRON_SCHEDULES) as CronJobName[];
const result: Record<string, {
label: string;
everyMs: number;
recent: Array<{ status: string; started_at: Date; duration_ms: number | null; items_processed: number; error_message: string | null }>;
}> = {};
for (const name of jobs) {
const recent = await this.execRepo.find({
where: { job_name: name },
order: { started_at: 'DESC' },
take: 10,
});
result[name] = {
label: CRON_JOB_LABELS[name],
everyMs: CRON_SCHEDULES[name].everyMs,
recent: recent.map((e) => ({
status: e.status,
started_at: e.started_at,
duration_ms: e.duration_ms,
items_processed: e.items_processed,
error_message: e.error_message,
})),
};
}
return result;
}
}
@@ -0,0 +1,111 @@
import { Inject, Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Queue } from 'bullmq';
import { Repository } from 'typeorm';
import { CRON_SCHEDULES, CRON_JOB_LABELS, CronJobName } from './cron-registry';
import { CRON_QUEUE } from './bull.module';
import { JobExecution } from './job-execution.entity';
/**
* منسّق المهام الدورية (O5).
*
* يعمل في عملية **الـAPI** فقط (ليس الـworker) لأنه يملك حقن كل الخدمات.
* مسؤولياته:
* 1. تسجيل المهام المتكررة (repeatable jobs) عند بدء التشغيل
* 2. قفل ضد التشغيل المزدوج (BullMQ concurrency = 1)
* 3. تعطيل المهام عند الإغلاق
*
* لا تُسجَّل المهام هنا — `CronWorker` هو من يprocessها ويكتب السجل.
*/
@Injectable()
export class CronOrchestratorService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger('CronOrchestrator');
private readonly registeredJobs = new Map<CronJobName, string>();
constructor(
@InjectRepository(JobExecution)
private readonly execRepo: Repository<JobExecution>,
@Inject(CRON_QUEUE)
private readonly queue: Queue,
) {}
async onModuleInit(): Promise<void> {
const runningJobs = await this.execRepo.find({
where: { status: 'running' },
order: { started_at: 'DESC' },
});
if (runningJobs.length > 0) {
this.logger.warn(
`found ${runningJobs.length} orphaned running job(s) — marking as timeout`,
);
for (const job of runningJobs) {
job.status = 'timeout';
job.finished_at = new Date();
job.error_message = 'Orphaned: server restarted while job was running';
await this.execRepo.save(job);
}
}
for (const [name, schedule] of Object.entries(CRON_SCHEDULES) as [CronJobName, (typeof CRON_SCHEDULES)[CronJobName]][]) {
await this.queue.upsertJobScheduler(
name,
{ every: schedule.everyMs },
{
name,
data: { name },
opts: {
removeOnComplete: 100,
removeOnFail: 50,
},
},
);
this.registeredJobs.set(name, name);
this.logger.log(`registered: ${name} (every ${schedule.everyMs / 1000}s)`);
}
this.logger.log(`${this.registeredJobs.size} cron job(s) registered`);
}
async onModuleDestroy(): Promise<void> {
for (const [name] of this.registeredJobs) {
await this.queue.removeJobScheduler(name);
}
this.logger.log(`removed ${this.registeredJobs.size} repeatable job(s)`);
}
async getStats() {
const stats: Array<{
name: string;
label: string;
everyMs: number;
lastExecution?: {
status: string;
started_at: Date;
duration_ms: number | null;
error_message: string | null;
};
}> = [];
for (const [name, schedule] of Object.entries(CRON_SCHEDULES) as [CronJobName, (typeof CRON_SCHEDULES)[CronJobName]][]) {
const last = await this.execRepo.findOne({
where: { job_name: name },
order: { started_at: 'DESC' },
});
stats.push({
name,
label: CRON_JOB_LABELS[name] ?? name,
everyMs: schedule.everyMs,
lastExecution: last
? {
status: last.status,
started_at: last.started_at,
duration_ms: last.duration_ms,
error_message: last.error_message,
}
: undefined,
});
}
return stats;
}
}
@@ -0,0 +1,51 @@
import { Logger, Module, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Queue, Worker, Job } from 'bullmq';
import { Repository } from 'typeorm';
import { JobExecution } from './job-execution.entity';
/**
* أسماء المهام الدورية — مصدر الحقيقة الوحيد.
* لا سلاسل نصية مبعثرة في الكود.
*/
export const CRON_JOBS = {
SCHEDULED_TRIPS_SWEEP: 'cron:scheduled_trips_sweep',
SEARCH_TIMEOUT_SWEEP: 'cron:search_timeout_sweep',
REWARDS_SWEEP: 'cron:rewards_sweep',
SURGE_RECALCULATE: 'cron:surge_recalculate',
RE_ENGAGEMENT: 'cron:re_engagement',
TRANSIT_APPROACHING_ALERTS: 'cron:transit_approaching_alerts',
TRANSIT_SYNC_MEMBERS: 'cron:transit_sync_members',
BOT_SOCIAL_TASKS: 'cron:bot_social_tasks',
} as const;
export type CronJobName = (typeof CRON_JOBS)[keyof typeof CRON_JOBS];
/**
* جدولة كل مهمة — كل مهمة لها فاصلها وحدّها الأقصى.
*/
export const CRON_SCHEDULES: Record<CronJobName, { everyMs: number; timeoutMs: number }> = {
[CRON_JOBS.SCHEDULED_TRIPS_SWEEP]: { everyMs: 30_000, timeoutMs: 15_000 },
[CRON_JOBS.SEARCH_TIMEOUT_SWEEP]: { everyMs: 60_000, timeoutMs: 30_000 },
[CRON_JOBS.REWARDS_SWEEP]: { everyMs: 60_000, timeoutMs: 30_000 },
[CRON_JOBS.SURGE_RECALCULATE]: { everyMs: 180_000, timeoutMs: 60_000 },
[CRON_JOBS.RE_ENGAGEMENT]: { everyMs: 86_400_000, timeoutMs: 120_000 },
[CRON_JOBS.TRANSIT_APPROACHING_ALERTS]: { everyMs: 60_000, timeoutMs: 30_000 },
[CRON_JOBS.TRANSIT_SYNC_MEMBERS]: { everyMs: 86_400_000, timeoutMs: 120_000 },
[CRON_JOBS.BOT_SOCIAL_TASKS]: { everyMs: 900_000, timeoutMs: 60_000 },
};
/**
* أسماء المهام الودية (للقسم).
*/
export const CRON_JOB_LABELS: Record<CronJobName, string> = {
[CRON_JOBS.SCHEDULED_TRIPS_SWEEP]: 'Release scheduled trips',
[CRON_JOBS.SEARCH_TIMEOUT_SWEEP]: 'Expire stuck searching trips',
[CRON_JOBS.REWARDS_SWEEP]: 'Pay out qualified referrals',
[CRON_JOBS.SURGE_RECALCULATE]: 'Recalculate surge multipliers',
[CRON_JOBS.RE_ENGAGEMENT]: 'Re-engagement notifications',
[CRON_JOBS.TRANSIT_APPROACHING_ALERTS]: 'Transit approaching station alerts',
[CRON_JOBS.TRANSIT_SYNC_MEMBERS]: 'Sync transit member groups',
[CRON_JOBS.BOT_SOCIAL_TASKS]: 'Execute social media bot tasks',
};
@@ -0,0 +1,176 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Worker, Job } from 'bullmq';
import { Repository } from 'typeorm';
import { CRON_JOBS, CronJobName } from './cron-registry';
import { CRON_QUEUE } from './bull.module';
import { JobExecution } from './job-execution.entity';
import { TripsService } from '../../modules/trips/trips.service';
import { ReferralsService } from '../../modules/rewards/referrals.service';
import { SurgeService } from '../../modules/tariff/surge.service';
import { MarketingService } from '../../modules/marketing/marketing.service';
import { BotService } from '../../modules/bots/bot.service';
/**
* معالج المهام الدورية (O5).
*
* يعمل في عملية **الـAPI** (ليس الـworker) لأنه يحتاج حقن الخدمات.
* كل مهمة تكتب سجل تنفيذ في `job_executions`.
*
* قفل ضد التشغيل المزدوج: `concurrency: 1` في الـWorker.
*/
@Injectable()
export class CronWorkerService {
private readonly logger = new Logger('CronWorker');
private worker?: Worker;
constructor(
@InjectRepository(JobExecution)
private readonly execRepo: Repository<JobExecution>,
private readonly config: ConfigService,
private readonly tripsService: TripsService,
private readonly referralsService: ReferralsService,
private readonly surgeService: SurgeService,
private readonly marketingService: MarketingService,
private readonly botService: BotService,
) {}
start(): void {
const connection = {
host: this.config.get<string>('redis.host'),
port: this.config.get<number>('redis.port'),
db: this.config.get<number>('redis.db'),
};
this.worker = new Worker(
CRON_QUEUE,
async (job: Job) => {
const name = job.name as CronJobName;
const execution = await this.startExecution(name);
try {
let itemsProcessed = 0;
switch (name) {
case CRON_JOBS.SCHEDULED_TRIPS_SWEEP:
itemsProcessed = await this.tripsService.releaseDueScheduled();
break;
case CRON_JOBS.SEARCH_TIMEOUT_SWEEP: {
const timeoutMs = this.config.get<number>('trips.searchTimeoutMs') ?? 900_000;
itemsProcessed = await this.tripsService.expireStuckSearches(timeoutMs);
break;
}
case CRON_JOBS.REWARDS_SWEEP:
itemsProcessed = await this.referralsService.sweep();
break;
case CRON_JOBS.SURGE_RECALCULATE: {
const result = await this.surgeService.recalculateAll();
itemsProcessed = result.updated + result.unchanged;
break;
}
case CRON_JOBS.RE_ENGAGEMENT: {
const campaigns = await this.marketingService.findDueReEngagement('*', 3);
let totalSent = 0;
for (const campaign of campaigns) {
const inactiveUserIds = await this.marketingService.findInactiveUserIds(
campaign.tenant_id,
3,
);
if (inactiveUserIds.length > 0) {
const result = await this.marketingService.runCampaign(campaign.id, inactiveUserIds);
totalSent += result.sent;
}
}
itemsProcessed = totalSent;
break;
}
case CRON_JOBS.TRANSIT_APPROACHING_ALERTS: {
this.logger.debug('transit approaching alerts: delegated to transit microservice');
itemsProcessed = 1;
break;
}
case CRON_JOBS.TRANSIT_SYNC_MEMBERS: {
this.logger.debug('transit sync members: delegated to transit microservice');
itemsProcessed = 1;
break;
}
case CRON_JOBS.BOT_SOCIAL_TASKS: {
const dueTasks = await this.botService.getDueTasks();
for (const task of dueTasks) {
await this.botService.updateStatus(task.id, 'running');
try {
await this.botService.updateStatus(task.id, 'completed', {
message: 'Task queued for execution',
});
} catch (e: any) {
await this.botService.updateStatus(task.id, 'failed');
}
}
itemsProcessed = dueTasks.length;
break;
}
default:
throw new Error(`Unknown cron job: ${name}`);
}
await this.finishExecution(execution, 'success', itemsProcessed);
if (itemsProcessed > 0) {
this.logger.log(`${name}: processed ${itemsProcessed} item(s)`);
}
} catch (err: any) {
await this.finishExecution(execution, 'failed', 0, err?.message);
this.logger.error(`${name} failed: ${err?.message}`);
throw err;
}
},
{
connection,
concurrency: 1,
prefix: this.config.get<string>('queue.prefix'),
},
);
this.worker.on('ready', () => this.logger.log('cron worker ready'));
this.worker.on('error', (err) => this.logger.error(`cron worker error: ${err.message}`));
}
async stop(): Promise<void> {
if (this.worker) {
await this.worker.close();
this.worker = undefined;
}
}
private async startExecution(jobName: string): Promise<JobExecution> {
const execution = this.execRepo.create({
job_name: jobName,
status: 'running',
started_at: new Date(),
});
return this.execRepo.save(execution);
}
private async finishExecution(
execution: JobExecution,
status: 'success' | 'failed',
itemsProcessed: number,
errorMessage?: string,
): Promise<void> {
const now = new Date();
execution.status = status;
execution.finished_at = now;
execution.duration_ms = now.getTime() - execution.started_at.getTime();
execution.items_processed = itemsProcessed;
execution.error_message = errorMessage ?? null;
await this.execRepo.save(execution);
}
}
@@ -0,0 +1,50 @@
import { Module, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { JobExecution } from './job-execution.entity';
import { BullModule } from './bull.module';
import { CronOrchestratorService } from './cron-orchestrator.service';
import { CronWorkerService } from './cron-worker.service';
import { CronAdminController } from './cron-admin.controller';
import { TripsModule } from '../../modules/trips/trips.module';
import { RewardsModule } from '../../modules/rewards/rewards.module';
import { TariffModule } from '../../modules/tariff/tariff.module';
import { MarketingModule } from '../../modules/marketing/marketing.module';
import { BotModule } from '../../modules/bots/bot.module';
/**
* وحدة المهام الدورية الموحّدة (O5).
*
* تسجّل كل مهمة دورية كـrepeatable job في BullMQ، وتعالجها في عملية واحدة
* مع `concurrency: 1` لمنع التشغيل المزدوج. كل تنفيذ يُسجَّل في
* `job_executions` — لا مهمة صامتة.
*
* لا تُستهلك في الـworker — الـworker يبقى مستقلاً لمفرّغ المواقع (5 ثوانٍ).
*/
@Module({
imports: [
TypeOrmModule.forFeature([JobExecution]),
BullModule,
TripsModule,
RewardsModule,
TariffModule,
MarketingModule,
BotModule,
],
controllers: [CronAdminController],
providers: [CronOrchestratorService, CronWorkerService],
exports: [CronOrchestratorService],
})
export class CronModule implements OnModuleInit, OnModuleDestroy {
constructor(
private readonly orchestrator: CronOrchestratorService,
private readonly worker: CronWorkerService,
) {}
onModuleInit(): void {
this.worker.start();
}
async onModuleDestroy(): Promise<void> {
await this.worker.stop();
}
}
@@ -0,0 +1,58 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
} from 'typeorm';
/**
* سجل تنفيذ المهام الدورية (O5).
*
* الجدول: `tripz_job_executions`.
*
* كل صف = تنفيذ واحد لأي مهمة كانت (sweeper, bot, report...).
* الهدف: إظهار آخر نجاح/فشل لكل مهمة + مدة التنفيذ + سبب الفشل.
* لا مهمة صامتة — كل تنفيذ يُسجَّل هنا.
*/
@Entity('job_executions')
@Index(['job_name', 'started_at'])
export class JobExecution {
@PrimaryGeneratedColumn('uuid')
id: string;
/** اسم المهمة (cron:scheduled_trips_sweep, cron:surge_recalculate...). */
@Column()
job_name: string;
/** حالة التنفيذ. */
@Column({ type: 'varchar', length: 20 })
status: 'running' | 'success' | 'failed' | 'timeout';
/** لحظة بدء التنفيذ الفعلي (لا وقت إضاف الطور). */
@Column({ type: 'timestamptz' })
started_at: Date;
/** لحظة انتهاء التنفيذ. */
@Column({ type: 'timestamptz', nullable: true })
finished_at: Date | null;
/** مدة التنفيذ بالميلي ثانية. */
@Column({ type: 'int', nullable: true })
duration_ms: number;
/** عدد العناصر التي عالجتها المهمة (مثلاً: عدد الرحلات التي انتهت). */
@Column({ type: 'int', default: 0 })
items_processed: number;
/** رسالة خطأ (فقط عند الفشل). */
@Column({ type: 'text', nullable: true })
error_message: string | null;
/** معرّف المستأجر (إن كانت المهمة خاصة بمستأجر). */
@Column({ type: 'uuid', nullable: true })
tenant_id: string | null;
@CreateDateColumn()
created_at: Date;
}
@@ -0,0 +1,63 @@
import {
createCipheriv,
createDecipheriv,
createHash,
createHmac,
randomBytes,
} from 'crypto';
/**
* تشفير الحقول الحساسة عند الراحة (at-rest) بـ AES-256-GCM مع IV عشوائي لكل قيمة
* (تشفير موثَّق authenticated — يمنع العبث). صيغة التخزين: v1:base64(iv|tag|ct).
* IV عشوائي 96-بت لكل عملية — لا IV ثابت إطلاقاً (راجع docs/16).
*
* للحقول القابلة للبحث بالتساوي (مثل الهاتف): تشفير GCM عشوائي لا يسمح بالمطابقة،
* فنستخدم blindIndex (HMAC حتمي) كعمود بحث منفصل — مخطّط لاحقاً.
*/
const VERSION = 'v1';
function getKey(): Buffer {
const raw = process.env.ENCRYPTION_KEY || '';
if (/^[0-9a-f]{64}$/i.test(raw)) return Buffer.from(raw, 'hex'); // 32 بايت hex
if (!raw) return createHash('sha256').update('tripz-dev-insecure-key').digest();
return createHash('sha256').update(raw).digest(); // أي سر → 32 بايت
}
export function encrypt(plain: string | null | undefined): string | null {
if (plain === null || plain === undefined) return plain as any;
const iv = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', getKey(), iv);
const ct = Buffer.concat([cipher.update(String(plain), 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return `${VERSION}:${Buffer.concat([iv, tag, ct]).toString('base64')}`;
}
export function decrypt(value: string | null | undefined): string | null {
if (value === null || value === undefined) return value as any;
if (typeof value !== 'string' || !value.startsWith(`${VERSION}:`)) {
return value as any; // نص قديم غير مشفّر — تسامح أثناء الانتقال
}
try {
const raw = Buffer.from(value.slice(VERSION.length + 1), 'base64');
const iv = raw.subarray(0, 12);
const tag = raw.subarray(12, 28);
const ct = raw.subarray(28);
const d = createDecipheriv('aes-256-gcm', getKey(), iv);
d.setAuthTag(tag);
return Buffer.concat([d.update(ct), d.final()]).toString('utf8');
} catch {
return value as any; // لا نُسقط القراءة عند قيمة تالفة/قديمة
}
}
/** فهرس أعمى حتمي للحقول القابلة للبحث (مثل الهاتف) — HMAC-SHA256. */
export function blindIndex(value: string): string {
const key = process.env.ENCRYPTION_KEY || 'tripz-dev-insecure-key';
return createHmac('sha256', key).update(value.trim().toLowerCase()).digest('hex');
}
/** محوّل TypeORM: يشفّر عند الكتابة ويفكّ عند القراءة تلقائياً. */
export const EncryptedTransformer = {
to: (v: string | null) => encrypt(v),
from: (v: string | null) => decrypt(v),
};
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { DeviceService } from './device.service';
@Global()
@Module({
providers: [DeviceService],
exports: [DeviceService],
})
export class DeviceModule {}
@@ -0,0 +1,16 @@
import { Injectable } from '@nestjs/common';
import { createHash } from 'crypto';
/**
* ربط الجلسة بالجهاز (docs/17 — D2؛ نمط سيرو).
*
* فلاتر يولّد بصمة جهاز محلية (device_info_plus) ويرسلها **مرة عند الدخول**
* ثم في ترويسة `x-device-id` مع كل طلب لاحق. البصمة الخام لا تُخزَّن — فقط
* بصمتها (hash) داخل التوكن، بنفس منطق عدم تخزين أسرار خام.
*/
@Injectable()
export class DeviceService {
hash(rawDeviceId: string): string {
return createHash('sha256').update(rawDeviceId ?? '').digest('hex');
}
}
@@ -0,0 +1,17 @@
import { Global, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Tenant } from '../../database/entities/tenant.entity';
import { EntitlementsService } from './entitlements.service';
import { FeatureGuard } from './feature.guard';
/**
* عالمي: `FeatureGuard` يُستعمل في وحدات كثيرة، وإجبار كلٍّ منها على استيراد
* الوحدة يعني نقطةً منسيّة يوماً ما — والنقطة المنسيّة ميزةٌ مجانية للجميع.
*/
@Global()
@Module({
imports: [TypeOrmModule.forFeature([Tenant])],
providers: [EntitlementsService, FeatureGuard],
exports: [EntitlementsService, FeatureGuard],
})
export class EntitlementsModule {}
@@ -0,0 +1,55 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Tenant } from '../../database/entities/tenant.entity';
import { CacheService, TTL } from '../cache/cache.service';
import { Entitlements, Feature, resolveEntitlements } from './features';
const key = (tenantId: string) => `entitlements:${tenantId}`;
/**
* استحقاقات المستأجر — Redis خط أول والقاعدة احتياط (docs/19 — K1).
* تُقرأ على كل نقطة محميّة، لذا لا يجوز أن تكون استعلام قاعدة في كل طلب.
*/
@Injectable()
export class EntitlementsService {
private readonly logger = new Logger('Entitlements');
constructor(
@InjectRepository(Tenant) private readonly tenants: Repository<Tenant>,
private readonly cache: CacheService,
) {}
async forTenant(tenantId: string): Promise<Entitlements | null> {
return this.cache.wrap<Entitlements>(key(tenantId), TTL.tenant, async () => {
const t = await this.tenants.findOne({
where: { id: tenantId },
select: { id: true, plan: true, features: true, status: true },
});
if (!t) return null;
return resolveEntitlements(t.plan, t.features ?? {});
});
}
/**
* هل يملك المستأجر هذه الميزة؟
* **مستأجر مجهول = ممنوع** — لا يُفترض السماح عند غياب المعلومة.
*/
async has(tenantId: string, feature: Feature): Promise<boolean> {
const e = await this.forTenant(tenantId);
return e?.features?.[feature] === true;
}
/** حدّ عددي؛ `undefined`/`null` = بلا حدّ. */
async limit(tenantId: string, name: 'drivers_max' | 'cities_max'): Promise<number | null> {
const e = await this.forTenant(tenantId);
const v = e?.limits?.[name];
return typeof v === 'number' ? v : null;
}
/** يُنادى بعد أي تغيير على الباقة/الميزات — وإلا سرى التغيير بعد ساعة. */
async invalidate(tenantId: string): Promise<void> {
await this.cache.del(key(tenantId));
this.logger.debug(`entitlements invalidated for ${tenantId}`);
}
}
@@ -0,0 +1,70 @@
import { ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { FeatureGuard } from './feature.guard';
import { EntitlementsService } from './entitlements.service';
const TENANT_WITH = 'tenant-with-bots';
const TENANT_WITHOUT = 'tenant-without-bots';
/** سياق تنفيذ مزيّف — `user` هو ما يحقنه JwtAuthGuard من التوكن الموقَّع. */
function ctx(user: any, headers: Record<string, string> = {}): ExecutionContext {
return {
switchToHttp: () => ({ getRequest: () => ({ user, headers }) }),
getHandler: () => ({}),
getClass: () => ({}),
} as any;
}
describe('FeatureGuard — الحدّ الأمني الحقيقي (docs/19 K1/K6)', () => {
let guard: FeatureGuard;
let reflector: Reflector;
let entitlements: EntitlementsService;
beforeEach(() => {
reflector = { getAllAndOverride: jest.fn() } as any;
entitlements = {
has: jest.fn(async (tenantId: string) => tenantId === TENANT_WITH),
} as any;
guard = new FeatureGuard(reflector, entitlements);
});
it('نقطة بلا @RequiresFeature تمرّ', async () => {
(reflector.getAllAndOverride as jest.Mock).mockReturnValue(undefined);
await expect(guard.canActivate(ctx({ tenantId: TENANT_WITHOUT }))).resolves.toBe(true);
expect(entitlements.has).not.toHaveBeenCalled();
});
it('مستأجر يملك الميزة يمرّ', async () => {
(reflector.getAllAndOverride as jest.Mock).mockReturnValue('bots');
await expect(guard.canActivate(ctx({ tenantId: TENANT_WITH }))).resolves.toBe(true);
});
it('مستأجر لا يملكها يُرفض بـ403', async () => {
(reflector.getAllAndOverride as jest.Mock).mockReturnValue('bots');
await expect(guard.canActivate(ctx({ tenantId: TENANT_WITHOUT }))).rejects.toThrow(
ForbiddenException,
);
});
it('الرسالة ثابتة ولا تكشف شيئاً عن مستأجرين آخرين', async () => {
(reflector.getAllAndOverride as jest.Mock).mockReturnValue('bots');
await expect(guard.canActivate(ctx({ tenantId: TENANT_WITHOUT }))).rejects.toThrow(
'feature_not_in_plan',
);
});
it('بلا مستخدم موثَّق = منع (الافتراض ليس السماح)', async () => {
(reflector.getAllAndOverride as jest.Mock).mockReturnValue('bots');
await expect(guard.canActivate(ctx(undefined))).rejects.toThrow(ForbiddenException);
});
it('لا يمكن تزوير المستأجر بترويسة — الحارس يقرأ التوكن وحده', async () => {
(reflector.getAllAndOverride as jest.Mock).mockReturnValue('bots');
// المهاجم يدّعي أنه المستأجر المالك للميزة عبر الترويسة…
const forged = ctx({ tenantId: TENANT_WITHOUT }, { 'x-tenant-id': TENANT_WITH });
await expect(guard.canActivate(forged)).rejects.toThrow(ForbiddenException);
// …والحارس سأل عن المستأجر القادم من التوكن لا من الترويسة.
expect(entitlements.has).toHaveBeenCalledWith(TENANT_WITHOUT, 'bots');
});
});
@@ -0,0 +1,51 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
SetMetadata,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { EntitlementsService } from './entitlements.service';
import { Feature } from './features';
export const FEATURE_KEY = 'required_feature';
/**
* يحرس نقطةً بميزة مشتراة (docs/19 — K1).
*
* **هذا هو الحدّ الأمني الحقيقي.** علم الميزة في التطبيق قرار *عرض* فقط؛ من
* يفكّك التطبيق ويفعّل الأعلام يرى الشاشة ثم يصطدم بـ403 من هنا.
*
* يجب أن يُستعمل **بعد** `JwtAuthGuard` — يعتمد على `tenant_id` القادم من
* التوكن الموقَّع، لا من ترويسة يكتبها العميل.
*/
export const RequiresFeature = (feature: Feature) => SetMetadata(FEATURE_KEY, feature);
@Injectable()
export class FeatureGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly entitlements: EntitlementsService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const feature = this.reflector.getAllAndOverride<Feature>(FEATURE_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!feature) return true; // النقطة غير محروسة بميزة
const { user } = context.switchToHttp().getRequest();
const tenantId = user?.tenantId;
// بلا مستأجر موثَّق لا نستطيع التحقق — والافتراض هو المنع.
if (!tenantId) throw new ForbiddenException('feature_not_in_plan');
if (!(await this.entitlements.has(tenantId, feature))) {
// رسالة ثابتة يعرضها التطبيق بلطف («هذه الميزة غير مشمولة باشتراكك»)،
// ولا تكشف للمهاجم أي ميزات يملكها المستأجرون الآخرون.
throw new ForbiddenException('feature_not_in_plan');
}
return true;
}
}
@@ -0,0 +1,55 @@
import { FEATURES, resolveEntitlements } from './features';
describe('resolveEntitlements — كتالوج الاستحقاقات (docs/19 K2)', () => {
it('الافتراض هو المنع: كل ميزة خارج الباقة محجوبة', () => {
const e = resolveEntitlements('launch');
expect(e.features.bots).toBe(false);
expect(e.features.market_intel).toBe(false);
expect(e.features.dispatch).toBe(false);
});
it('كل ميزة في الكتالوج لها قيمة صريحة — لا undefined يُقرأ كسماح', () => {
const e = resolveEntitlements('launch');
for (const f of FEATURES) {
expect(typeof e.features[f]).toBe('boolean');
}
});
it('الباقة تعطي افتراضاتها', () => {
expect(resolveEntitlements('launch').features.wallet).toBe(true);
expect(resolveEntitlements('fleet').features.dispatch).toBe(true);
expect(resolveEntitlements('brand').features.dispatch).toBe(false);
});
it('السيادة تشمل كل شيء — قرار مقصود (docs/19 §6)', () => {
const e = resolveEntitlements('sovereign');
for (const f of FEATURES) expect(e.features[f]).toBe(true);
});
it('ميزة مشتراة منفردة فوق الباقة', () => {
const e = resolveEntitlements('brand', { bots: true });
expect(e.features.bots).toBe(true);
expect(e.features.wallet).toBe(true); // افتراض الباقة باقٍ
});
it('سحب ميزة من الباقة صراحةً', () => {
expect(resolveEntitlements('fleet', { dispatch: false }).features.dispatch).toBe(false);
});
it('مفاتيح مجهولة تُتجاهل — لا تفتح شيئاً', () => {
const e = resolveEntitlements('launch', { not_a_feature: true } as any);
expect((e.features as any).not_a_feature).toBeUndefined();
expect(e.features.bots).toBe(false);
});
it('الحدود العددية من الباقة، وتُتجاوز صراحةً', () => {
expect(resolveEntitlements('launch').limits.drivers_max).toBe(50);
expect(resolveEntitlements('launch', { limits: { drivers_max: 200 } }).limits.drivers_max).toBe(200);
});
it('باقة مجهولة تسقط لأضيق باقة لا لأوسعها', () => {
const e = resolveEntitlements('nonsense' as any);
expect(e.features.bots).toBe(false);
expect(e.limits.drivers_max).toBe(50);
});
});
@@ -0,0 +1,98 @@
import { TenantPlan } from '../../database/entities/tenant.entity';
/**
* كتالوج الميزات القابلة للبيع (docs/19 — K2).
* الميزة تُشترى منفردة فوق الباقة، فـ`plan` يعطي الافتراضات و`tenants.features`
* هي الحقيقة النهائية.
*/
export const FEATURES = [
'dispatch', // لوحة المشغّل — إنشاء رحلة نيابةً عن راكب
'wallet', // محفظة الراكب/السائق + التسوية
'payments', // بوابات الدفع والشحن
'chat', // دردشة داخل الرحلة
'calls', // مكالمات WebRTC
'ride_types', // أنواع رحلات مخصّصة للمستأجر
'market_intel', // الاستخبار السوقي
'bots', // البوتات
'ads', // الدعاية
'transit', // نظام المواصلات
'api_access', // API + Webhooks للمستأجر
'driver_tiers', // مستويات السائقين (برونزي/فضي/ذهبي/بلاتيني)
'marketing_engine', // محرك التسويق التلقائي
'dynamic_pricing', // التسعير الديناميكي (مضاعف الطلب)
'geofence', // السياج الجغرافي — مناطق التسعير والتنبيهات
'negotiator', // المفاوض الآلي — تفاوض السعر تلقائياً
'driver_assurance', // تأمين السائق — تغطية تأمينية
'coupons', // كوبونات الخصم
] as const;
export type Feature = (typeof FEATURES)[number];
/** الحدود العددية — تُفرض على السيرفر أيضاً (docs/19 — K4). */
export interface Limits {
drivers_max?: number;
cities_max?: number;
}
export interface Entitlements {
features: Record<Feature, boolean>;
limits: Limits;
}
/** `null` = بلا حدّ. */
const UNLIMITED = null;
/**
* افتراضات كل باقة (docs/05 — انطلاقة/علامة/أسطول+/سيادة).
* ما لا يُذكر هنا = **ممنوع**؛ الافتراض هو المنع لا السماح.
*/
const PLAN_DEFAULTS: Record<TenantPlan, { features: Feature[]; limits: Limits }> = {
launch: {
features: ['wallet', 'chat', 'ride_types'],
limits: { drivers_max: 50, cities_max: 1 },
},
brand: {
features: ['wallet', 'chat', 'calls', 'ride_types', 'payments', 'driver_tiers'],
limits: { drivers_max: 500, cities_max: 3 },
},
fleet: {
features: ['wallet', 'chat', 'calls', 'ride_types', 'payments', 'dispatch', 'api_access'],
limits: { drivers_max: 5000, cities_max: 20 },
},
// السيادة: كل شيء مشمول — قرار مقصود (docs/19 §6). المستأجر يشغّل النسخة على
// خوادمه، فلا يوجد حارس يحرس ضدّه؛ الحجب هناك وهمٌ لا حماية. الحدّ تعاقدي.
sovereign: {
features: [...FEATURES],
limits: { drivers_max: UNLIMITED as any, cities_max: UNLIMITED as any },
},
};
/** خريطة «كل الميزات ممنوعة» — نقطة البداية دائماً (default-deny). */
function noFeatures(): Record<Feature, boolean> {
return Object.fromEntries(FEATURES.map((f) => [f, false])) as Record<Feature, boolean>;
}
/**
* يحسب استحقاقات المستأجر: افتراضات الباقة، ثم تجاوزات `tenants.features`
* الصريحة (شراء ميزة منفردة أو سحبها).
*
* **مفتاح غير موجود = ممنوع.** قائمة بيضاء لا سوداء — ميزة جديدة تُضاف للكتالوج
* تبقى محجوبة عن الجميع حتى تُمنح صراحةً، لا العكس.
*/
export function resolveEntitlements(
plan: TenantPlan,
overrides: Record<string, any> = {},
): Entitlements {
const base = PLAN_DEFAULTS[plan] ?? PLAN_DEFAULTS.launch;
const features = noFeatures();
for (const f of base.features) features[f] = true;
// تجاوزات صريحة — تقبل المنح والسحب معاً.
for (const f of FEATURES) {
if (typeof overrides?.[f] === 'boolean') features[f] = overrides[f];
}
const limits: Limits = { ...base.limits, ...(overrides?.limits ?? {}) };
return { features, limits };
}
@@ -0,0 +1,29 @@
/**
* كتالوج بوابات الدفع المتاحة (docs/22 — N) والدول التي تخدمها.
* السوبر-أدمن يفعّل ما يناسب دولة المستأجر عند التزويد.
*/
export const PAYMENT_METHODS = [
{ code: 'cash', name_ar: 'نقداً', countries: ['jo', 'sy', 'eg'] },
{ code: 'wallet', name_ar: 'المحفظة', countries: ['jo', 'sy', 'eg'] },
{ code: 'cliq', name_ar: 'كليك', countries: ['jo'] },
{ code: 'zaincash', name_ar: 'زين كاش', countries: ['jo'] },
{ code: 'paymob', name_ar: 'باي موب', countries: ['eg'] },
{ code: 'mtn', name_ar: 'MTN', countries: ['sy'] },
{ code: 'syriatel', name_ar: 'سيرياتيل كاش', countries: ['sy'] },
{ code: 'shamcash', name_ar: 'شام كاش', countries: ['sy'] },
] as const;
export type PaymentMethodCode = (typeof PAYMENT_METHODS)[number]['code'];
/** الوسائل الافتراضية لدولة (نقد + محفظة + بوابات الدولة) — نقطة بداية التزويد. */
export function defaultPaymentMethods(countryPack: string): string[] {
return PAYMENT_METHODS.filter((m) => (m.countries as readonly string[]).includes(countryPack)).map(
(m) => m.code,
);
}
/** يتحقق أن الوسائل المطلوبة كلها في الكتالوج (لا وسيلة مخترعة). */
export function validPaymentMethods(codes: string[]): string[] {
const known = new Set<string>(PAYMENT_METHODS.map((m) => m.code));
return (codes ?? []).filter((c) => known.has(c));
}
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { I18nService } from './i18n.service';
@Global()
@Module({
providers: [I18nService],
exports: [I18nService],
})
export class I18nModule {}
@@ -0,0 +1,32 @@
import { I18nService } from './i18n.service';
describe('I18nService', () => {
const i18n = new I18nService();
it('يترجم للعربية افتراضياً ويستبدل المتغيّرات', () => {
const m = i18n.t(null, 'trip.assigned', { driverName: 'أحمد' });
expect(m.title).toBe('تم قبول رحلتك');
expect(m.body).toBe('سائقك أحمد في الطريق إليك');
});
it('يحترم لغة المستخدم الإنجليزية', () => {
const m = i18n.t('en', 'trip.driver_arrived');
expect(m.title).toBe('Driver has arrived');
});
it('يطبّع وسوم اللغة المركّبة (ar-JO, EN_us)', () => {
expect(i18n.normalize('ar-JO')).toBe('ar');
expect(i18n.normalize('EN_us')).toBe('en');
expect(i18n.normalize('fr')).toBe('ar'); // غير مدعومة → الافتراضية
expect(i18n.normalize(undefined)).toBe('ar');
});
it('يترك المتغيّر كما هو إذا لم تُمرَّر قيمته', () => {
const m = i18n.t('ar', 'trip.completed', { currency: 'JOD' });
expect(m.body).toBe('وصلت إلى وجهتك. الأجرة {fare} JOD');
});
it('لا يرمي عند مفتاح مفقود', () => {
expect(() => i18n.t('ar', 'does.not.exist')).not.toThrow();
});
});
@@ -0,0 +1,34 @@
import { Injectable, Logger } from '@nestjs/common';
import { CATALOGS, DEFAULT_LANG, Lang, Message, SUPPORTED_LANGS } from './messages';
/** ترجمة نصوص الإشعارات (docs/17 — A2). العربية هي الافتراض. */
@Injectable()
export class I18nService {
private readonly logger = new Logger('I18n');
/** يطبّع أي وسم لغة (ar-JO, AR, en_US) إلى لغة مدعومة، وإلا الافتراضية. */
normalize(lang?: string | null): Lang {
const base = (lang ?? '').toLowerCase().split(/[-_]/)[0];
return (SUPPORTED_LANGS as string[]).includes(base) ? (base as Lang) : DEFAULT_LANG;
}
/** يترجم مفتاحاً مع استبدال المتغيّرات. المفتاح المفقود يرجع كما هو (لا يكسر الإرسال). */
t(lang: string | null | undefined, key: string, params: Record<string, any> = {}): Message {
const l = this.normalize(lang);
const msg = CATALOGS[l][key] ?? CATALOGS[DEFAULT_LANG][key];
if (!msg) {
this.logger.warn(`missing i18n key "${key}"`);
return { title: key, body: '' };
}
return {
title: this.interpolate(msg.title, params),
body: this.interpolate(msg.body, params),
};
}
private interpolate(tpl: string, params: Record<string, any>): string {
return tpl.replace(/\{(\w+)\}/g, (match, name) =>
params[name] == null ? match : String(params[name]),
);
}
}
@@ -0,0 +1,51 @@
/**
* نصوص الإشعارات لكل لغة. المفتاح يُشتق من نوع الحدث (راجع docs/17 — A2).
* كل مدخل: عنوان + نص. المتغيّرات بصيغة {name}.
*/
export type Lang = 'ar' | 'en';
export const DEFAULT_LANG: Lang = 'ar';
export const SUPPORTED_LANGS: Lang[] = ['ar', 'en'];
export interface Message {
title: string;
body: string;
}
type Catalog = Record<string, Message>;
const ar: Catalog = {
'trip.assigned': { title: 'تم قبول رحلتك', body: 'سائقك {driverName} في الطريق إليك' },
'trip.driver_arriving': { title: 'السائق في الطريق', body: 'سائقك متوجّه إلى نقطة الانطلاق' },
'trip.driver_arrived': { title: 'وصل السائق', body: 'سائقك ينتظرك في نقطة الانطلاق' },
'trip.in_progress': { title: 'بدأت الرحلة', body: 'رحلتك جارية الآن — رحلة موفقة' },
'trip.completed': { title: 'انتهت الرحلة', body: 'وصلت إلى وجهتك. الأجرة {fare} {currency}' },
'trip.paid': { title: 'تم الدفع', body: 'تم استلام مبلغ {fare} {currency}' },
'trip.cancelled': { title: 'أُلغيت الرحلة', body: 'تم إلغاء الرحلة' },
'trip.cancelled_fee': { title: 'أُلغيت الرحلة', body: 'تم إلغاء الرحلة ورسم الإلغاء {fee} {currency}' },
'trip.no_drivers': { title: 'لا يوجد سائقون', body: 'لم نجد سائقاً متاحاً قريباً منك. حاول مجدداً' },
'trip.expired': { title: 'انتهت مهلة الطلب', body: 'انتهت مهلة طلب الرحلة دون قبول' },
'trip.offer': { title: 'طلب رحلة جديد', body: 'راكب على بعد {distanceKm} كم — الأجرة {fare} {currency}' },
'trip.offer_taken': { title: 'الرحلة لم تعد متاحة', body: 'قبِل الطلبَ سائق آخر' },
'trip.audio_recording_started': { title: 'بدء التسجيل', body: 'تم بدء تسجيل الرحلة صوتياً لضمان سلامتكما' },
'chat.message': { title: 'رسالة جديدة', body: '{preview}' },
};
const en: Catalog = {
'trip.assigned': { title: 'Your ride is accepted', body: 'Your driver {driverName} is on the way' },
'trip.driver_arriving': { title: 'Driver on the way', body: 'Your driver is heading to the pickup point' },
'trip.driver_arrived': { title: 'Driver has arrived', body: 'Your driver is waiting at the pickup point' },
'trip.in_progress': { title: 'Trip started', body: 'Your trip is now in progress — have a good ride' },
'trip.completed': { title: 'Trip finished', body: 'You have arrived. Fare {fare} {currency}' },
'trip.paid': { title: 'Payment received', body: 'Payment of {fare} {currency} received' },
'trip.cancelled': { title: 'Trip cancelled', body: 'The trip was cancelled' },
'trip.cancelled_fee': { title: 'Trip cancelled', body: 'The trip was cancelled with a {fee} {currency} cancellation fee' },
'trip.no_drivers': { title: 'No drivers', body: 'No driver available nearby. Please try again' },
'trip.expired': { title: 'Request expired', body: 'The ride request expired without being accepted' },
'trip.offer': { title: 'New ride request', body: 'Rider {distanceKm} km away — fare {fare} {currency}' },
'trip.offer_taken': { title: 'Ride no longer available', body: 'Another driver accepted the request' },
'trip.audio_recording_started': { title: 'Recording started', body: 'Trip audio recording has started for your safety' },
'chat.message': { title: 'New message', body: '{preview}' },
};
export const CATALOGS: Record<Lang, Catalog> = { ar, en };
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PhoneService } from './phone.service';
@Global()
@Module({
providers: [PhoneService],
exports: [PhoneService],
})
export class PhoneModule {}
@@ -0,0 +1,84 @@
import { BadRequestException } from '@nestjs/common';
import { PhoneService } from './phone.service';
describe('PhoneService — تطبيع أرقام الهاتف (docs/17 D1)', () => {
let phones: PhoneService;
beforeEach(() => {
phones = new PhoneService();
});
describe('الأردن (jo)', () => {
it('الصيغة المحلية بالصفر', () => {
expect(phones.normalize('0790000000', 'jo')).toBe('962790000000');
});
it('الصيغة الدولية بالفعل', () => {
expect(phones.normalize('962790000000', 'jo')).toBe('962790000000');
});
it('بادئة + دولية', () => {
expect(phones.normalize('+962790000000', 'jo')).toBe('962790000000');
});
it('بادئة 00 بدل +', () => {
expect(phones.normalize('00962790000000', 'jo')).toBe('962790000000');
});
it('فراغات وشرطات لا تؤثر', () => {
expect(phones.normalize('+962 79-000-0000', 'jo')).toBe('962790000000');
});
it('محلي بلا صفر بادئ', () => {
expect(phones.normalize('790000000', 'jo')).toBe('962790000000');
});
});
describe('مصر (eg) — لبس المالك: "01" يُظنّ مفتاحاً وهو ليس كذلك', () => {
it('الصيغة المحلية بالصفر (11 رقماً) تُحسب بمفتاح 20 الصحيح لا "2"', () => {
const r = phones.normalize('01012345678', 'eg');
expect(r).toBe('201012345678');
expect(r.startsWith('20')).toBe(true); // لا "2" فقط
});
it('الصيغة الدولية بالفعل', () => {
expect(phones.normalize('201012345678', 'eg')).toBe('201012345678');
});
it('بادئة +', () => {
expect(phones.normalize('+201012345678', 'eg')).toBe('201012345678');
});
});
describe('سوريا (sy)', () => {
it('الصيغة المحلية بالصفر', () => {
expect(phones.normalize('0944000000', 'sy')).toBe('963944000000');
});
});
describe('نفس الرقم الحقيقي بصيغ مختلفة → مفتاح واحد فقط', () => {
it('لا يتعدد الحساب لنفس الرقم', () => {
const variants = ['0790000000', '962790000000', '+962790000000', '00962790000000'];
const results = new Set(variants.map((v) => phones.normalize(v, 'jo')));
expect(results.size).toBe(1);
});
});
describe('مدخلات غير صالحة', () => {
it('طول خاطئ يُرفض لا يُخمَّن', () => {
expect(() => phones.normalize('12345', 'jo')).toThrow(BadRequestException);
});
it('نص فارغ يُرفض', () => {
expect(() => phones.normalize('', 'jo')).toThrow(BadRequestException);
});
it('حروف بلا أرقام تُرفض', () => {
expect(() => phones.normalize('abc-def', 'jo')).toThrow(BadRequestException);
});
});
it('دولة غير معروفة تسقط لخطة افتراضية بلا انهيار', () => {
expect(() => phones.normalize('0790000000', 'xx')).not.toThrow();
});
});
@@ -0,0 +1,76 @@
import { BadRequestException, Injectable } from '@nestjs/common';
/**
* قاعدة ترقيم كل دولة (docs/17 — D1).
* `trunkPrefix`: الصفر المحلي الذي يُكتب فيُحذف قبل إضافة مفتاح الدولة.
* `nationalLength`: طول الرقم المحلي **بعد** حذف الصفر.
*/
interface DialPlan {
callingCode: string; // بلا +
nationalLength: number;
}
const DIAL_PLANS: Record<string, DialPlan> = {
// 07XXXXXXXX (10 أرقام بالصفر) → مفتاح 962 + 7XXXXXXXX (9 أرقام)
jo: { callingCode: '962', nationalLength: 9 },
// 09XXXXXXXX (10 أرقام بالصفر) → مفتاح 963 + 9XXXXXXXX (9 أرقام)
sy: { callingCode: '963', nationalLength: 9 },
// 01XXXXXXXXX (11 رقماً بالصفر) → مفتاح 20 + 1XXXXXXXXX (10 أرقام).
// هذا بالضبط اللبس الذي ذكره المالك: الناس تظن المفتاح "2" لا "20"، لأن
// الرقم المحلي نفسه يبدأ بـ1 فيبدو للوهلة الأولى أن "01" بأكملها مفتاح.
eg: { callingCode: '20', nationalLength: 10 },
};
/**
* تطبيع رقم الهاتف لصيغة دولية قانونية موحّدة (docs/17 — D1).
*
* **بلا هذا**: نفس الرقم الحقيقي يُكتب "0790000000" مرة و"962790000000"
* مرة و"+962790000000" مرة ثالثة — فيصير له 3 حسابات مختلفة. المفتاح
* المخزَّن في القاعدة يجب أن يكون **دالة واحدة فقط** لكل رقم حقيقي.
*/
@Injectable()
export class PhoneService {
/**
* يرجع الصيغة الدولية بلا "+" (مثل "962790000000") — هذا هو المفتاح
* القانوني الوحيد المخزَّن والمُقارَن به في كل مكان.
*/
normalize(raw: string, countryPack: string): string {
const plan = DIAL_PLANS[countryPack] ?? DIAL_PLANS.jo;
const digits = PhoneService.stripToDigits(raw);
if (!digits) throw new BadRequestException('invalid phone number');
// "00" بادئة دولية بديلة عن "+" — إزالتها تعيدنا لحالة "مفتاح كامل".
const noIntlPrefix = digits.startsWith('00') ? digits.slice(2) : digits;
// 1) مكتوب بمفتاح الدولة كاملاً (مع أو بلا +/00 — أُزيلا أعلاه).
if (
noIntlPrefix.startsWith(plan.callingCode) &&
noIntlPrefix.length === plan.callingCode.length + plan.nationalLength
) {
return noIntlPrefix;
}
// 2) الصيغة المحلية المعتادة: صفر بادئ ثم الرقم المحلي.
if (noIntlPrefix.startsWith('0') && noIntlPrefix.length === plan.nationalLength + 1) {
return plan.callingCode + noIntlPrefix.slice(1);
}
// 3) رقم محلي بلا صفر بادئ (بعض المستخدمين يحذفونه بأنفسهم).
if (noIntlPrefix.length === plan.nationalLength) {
return plan.callingCode + noIntlPrefix;
}
throw new BadRequestException(
`invalid phone number for country "${countryPack}"`,
);
}
/** للعرض/الإرسال فقط — Nabeh والتطبيق يريدان الصيغة الدولية بلا +. */
toWhatsApp(normalized: string): string {
return normalized;
}
private static stripToDigits(raw: string): string {
return (raw ?? '').replace(/[^\d]/g, '');
}
}
@@ -0,0 +1,49 @@
import { ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { PlatformGuard } from './platform.guard';
const SECRET = 'super-secret-value';
function ctx(headers: Record<string, any> = {}): ExecutionContext {
return { switchToHttp: () => ({ getRequest: () => ({ headers }) }) } as any;
}
const guardWith = (secret?: string) =>
new PlatformGuard({ get: () => secret } as any);
describe('PlatformGuard — حارس السوبر-أدمن', () => {
it('السرّ الصحيح يمرّ', () => {
expect(guardWith(SECRET).canActivate(ctx({ 'x-platform-secret': SECRET }))).toBe(true);
});
it('سرّ خاطئ يُرفض', () => {
expect(() => guardWith(SECRET).canActivate(ctx({ 'x-platform-secret': 'nope' }))).toThrow(
UnauthorizedException,
);
});
it('بلا ترويسة يُرفض', () => {
expect(() => guardWith(SECRET).canActivate(ctx())).toThrow(UnauthorizedException);
});
it('سرّ غير مضبوط = منع الجميع، لا فتح الباب', () => {
// الفخّ الخطير: إعداد ناقص يفتح نقاط المنصة للعالم.
expect(() => guardWith(undefined).canActivate(ctx({ 'x-platform-secret': 'anything' }))).toThrow(
UnauthorizedException,
);
expect(() => guardWith('').canActivate(ctx({ 'x-platform-secret': '' }))).toThrow(
UnauthorizedException,
);
});
it('لا ينهار على ترويسة غير نصّية', () => {
expect(() => guardWith(SECRET).canActivate(ctx({ 'x-platform-secret': ['a', 'b'] }))).toThrow(
UnauthorizedException,
);
});
it('طول مختلف يُرفض بلا انهيار (timingSafeEqual يشترط تطابق الطول)', () => {
expect(() => guardWith(SECRET).canActivate(ctx({ 'x-platform-secret': 'short' }))).toThrow(
UnauthorizedException,
);
});
});
@@ -0,0 +1,50 @@
import {
CanActivate,
ExecutionContext,
Injectable,
Logger,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { timingSafeEqual } from 'crypto';
/**
* حارس السوبر-أدمن (مالك المنصة) — يحرس `admin/*` و`platform/*`.
*
* السوبر-أدمن يدير **كل** المستأجرين، فهو خارج نموذج أدوار المستأجر تماماً
* (rider/driver/dispatcher/admin): أدمن المستأجر «سيرو» يجب ألّا يرقّي اشتراكه
* بنفسه. لذلك المصادقة بسرّ المنصة لا بـJWT مستأجر.
*
* مؤقّت حتى يوجد نموذج مستخدم منصة حقيقي؛ يوثَّق في docs/19.
*/
@Injectable()
export class PlatformGuard implements CanActivate {
private readonly logger = new Logger('PlatformGuard');
constructor(private readonly config: ConfigService) {}
canActivate(context: ExecutionContext): boolean {
const req = context.switchToHttp().getRequest();
const provided = req.headers?.['x-platform-secret'];
const expected = this.config.get<string>('platform.secret');
// سرّ غير مضبوط = **منع الجميع**، لا سماح للجميع. تركه فارغاً في الإنتاج
// خطأ تشغيلي شائع، وفتحُ الباب عنده أسوأ من إغلاقه.
if (!expected) {
this.logger.error('PLATFORM_SECRET is not set — refusing all platform access');
throw new UnauthorizedException('platform access disabled');
}
if (typeof provided !== 'string' || !PlatformGuard.safeEqual(provided, expected)) {
throw new UnauthorizedException('invalid platform secret');
}
return true;
}
/** مقارنة ثابتة الزمن — المقارنة العادية تسرّب السرّ حرفاً حرفاً بالتوقيت. */
private static safeEqual(a: string, b: string): boolean {
const ba = Buffer.from(a);
const bb = Buffer.from(b);
if (ba.length !== bb.length) return false;
return timingSafeEqual(ba, bb);
}
}
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PlatformGuard } from './platform.guard';
@Global()
@Module({
providers: [PlatformGuard],
exports: [PlatformGuard],
})
export class PlatformModule {}
@@ -0,0 +1,29 @@
import { Global, Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import Redis from 'ioredis';
export const REDIS = 'REDIS_CLIENT';
/**
* عميل Redis عالمي — DB رقم 3 وبادئة مفاتيح tripz: للعزل على السيرفر المشترك
* (راجع docs/14). يُستخدم لتخزين OTP والحضور والمطابقة لاحقاً.
*/
@Global()
@Module({
providers: [
{
provide: REDIS,
inject: [ConfigService],
useFactory: (cfg: ConfigService) =>
new Redis({
host: cfg.get<string>('redis.host'),
port: cfg.get<number>('redis.port'),
db: cfg.get<number>('redis.db'),
keyPrefix: cfg.get<string>('redis.keyPrefix'),
maxRetriesPerRequest: null,
}),
},
],
exports: [REDIS],
})
export class RedisModule {}
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { SeedService } from './seed.service';
import { TenantsModule } from '../../modules/tenants/tenants.module';
import { TariffModule } from '../../modules/tariff/tariff.module';
import { RideTypesModule } from '../../modules/ride-types/ride-types.module';
@Module({
imports: [TenantsModule, TariffModule, RideTypesModule],
providers: [SeedService],
})
export class SeedModule {}
@@ -0,0 +1,94 @@
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,
SEEDED_CLASSES,
} from '../../modules/tariff/default-tariffs';
/**
* seed تلقائي عند الإقلاع: مستأجر تجريبي (Tenant Zero) + تعرفة افتراضية،
* حتى لا نعيد إنشاءهما يدوياً بعد كل `docker compose down -v`.
*/
@Injectable()
export class SeedService implements OnModuleInit {
private readonly logger = new Logger('Seed');
constructor(
private readonly tenants: TenantsService,
private readonly tariff: TariffService,
private readonly rideTypes: RideTypesService,
) {}
async onModuleInit() {
try {
await this.run();
} catch (e: any) {
// على قاعدة جديدة قد تسبق onModuleInit تشغيلَ الهجرات — لا نُسقط الإقلاع.
this.logger.warn(`seed skipped (run migrations then restart api): ${e?.message ?? e}`);
}
}
private async run() {
let tenant = await this.tenants.findBySlug('siro');
if (!tenant) {
tenant = await this.tenants.create({
name: 'Siro (Demo — Tenant Zero)',
slug: 'siro',
countryPack: 'jo',
// سيرو = المشترك الأول بالباقة الكاملة (قرار المالك)، وسيادةً تعني كل
// الميزات بلا حدود عددية (docs/19 §6). عملياً أيضاً: أي باقة أدنى كانت
// ستحجب dispatch وتُسقط سكربتات التحقق بـ403 وحدّ السائقين.
plan: 'sovereign',
});
this.logger.log(`seeded demo tenant "siro" (${tenant.id})`);
}
// تعرفة الانطلاق **لكل فئة خدمة** من مصدر الحقيقة الواحد
// (`default-tariffs.ts`)، لا الاقتصادي وحده: طلب فئة بلا تعرفة كان
// يمرّ بلا سعر ثم يُسوّى بصفر — رحلة مجانية وعمولة صفر.
// البلد من `country_pack` للمستأجر، والمدينة `'default'` لأنها القيمة
// التي يبحث بها `trips.service` فعلاً.
const country = countryTariff(tenant.countryPack);
let created = 0;
let skipped = 0;
for (const serviceClass of SEEDED_CLASSES) {
const existing = await this.tariff.getActive(tenant.id, 'default', serviceClass);
if (existing) {
skipped++;
continue;
}
created++;
await this.tariff.create({
tenant_id: tenant.id,
city: 'default',
service_class: serviceClass,
version: 1,
active: true,
definition: buildDefinition(country, serviceClass),
});
}
// العدد الحقيقي لا ثابت: الرسالة السابقة طبعت 9 دائماً حتى حين تُتخطّى
// كل الفئات، فلم تكن تُميّز «زُرعت» من «كانت موجودة» — وهو الفرق الوحيد
// الذي يُقرأ من هذا السطر أصلاً.
this.logger.log(
`tariffs for "${tenant.slug}" (${country.currency}): ${created} seeded, ${skipped} already active`,
);
// أنواع الرحلات الافتراضية
const types: any[] = [
{ code: 'economy', name_ar: 'اقتصادي', name_en: 'Economy', vehicle_kind: 'car', sort: 1 },
{ code: 'comfort', name_ar: 'مريح', name_en: 'Comfort', vehicle_kind: 'car', sort: 2 },
{ code: 'electric', name_ar: 'كهربائية', name_en: 'Electric', vehicle_kind: 'car', sort: 3 },
{ code: 'family_van', name_ar: 'باص عائلي', name_en: 'Family Van', vehicle_kind: 'van', sort: 4 },
{ code: 'women', name_ar: 'سيدات', name_en: 'Women', vehicle_kind: 'car', women_only: true, sort: 5 },
{ code: 'scooter', name_ar: 'سكوتر توصيل', name_en: 'Scooter', vehicle_kind: 'scooter', round_trip_supported: false, sort: 6 },
];
for (const t of types) {
await this.rideTypes.ensure({ ...t, tenant_id: tenant.id });
}
this.logger.log('seeded ride types for "siro"');
}
}
@@ -0,0 +1,70 @@
import {
CanActivate,
ExecutionContext,
Injectable,
Logger,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { SigningService } from './signing.service';
import { AuditService } from '../audit/audit.service';
/**
* يحرس العمليات المالية بتوقيع HMAC (docs/17 — I6).
*
* **مطفأ افتراضياً** (`PAYMENTS_REQUIRE_SIGNATURE=false`): التوقيع يحتاج فلاتر
* أن يوقّع أولاً، وتفعيله قبل ذلك يقطع كل سحب. يُفعَّل بمتغيّر بيئة فور جاهزية
* التطبيق — بلا نشر كود.
*
* ما يقدّمه فعلاً: الـAPI يعمل على **http** بلا TLS، فمن يلتقط الشبكة يسرق
* التوكن. التوقيع بمفتاح جلسة (ليس في التوكن ولا في الـAPK) يجعل التوكن
* المسروق وحده غير كافٍ لتوقيع سحب، ويمنع تبديل المبلغ في الطريق.
* **هذا ترقيع لغياب TLS لا بديل عنه** — راجع docs/17.
*/
@Injectable()
export class SignatureGuard implements CanActivate {
private readonly logger = new Logger('SignatureGuard');
constructor(
private readonly signing: SigningService,
private readonly config: ConfigService,
private readonly audit: AuditService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
if (!this.config.get<boolean>('payments.requireSignature')) return true;
const req = context.switchToHttp().getRequest();
const user = req.user;
if (!user?.userId || !user?.tenantId) throw new UnauthorizedException('unsigned_request');
const signature = req.headers?.['x-signature'];
const timestamp = req.headers?.['x-timestamp'];
if (typeof signature !== 'string' || typeof timestamp !== 'string') {
throw new UnauthorizedException('unsigned_request');
}
const reason = await this.signing.verify(
user.tenantId,
user.userId,
signature,
timestamp,
req.method,
req.originalUrl?.split('?')[0] ?? req.url,
// الجسم الخام كما وصل؛ إعادة تسلسله قد تُغيّر ترتيب المفاتيح فيفشل توقيع سليم.
req.rawBody?.toString() ?? JSON.stringify(req.body ?? {}),
);
if (reason) {
await this.audit.record({
tenantId: user.tenantId,
actorUserId: user.userId,
action: 'signature.rejected',
ip: req.ip ?? null,
meta: { reason, path: req.url },
});
// رسالة واحدة لكل الأسباب — لا نُعلّم المهاجم أين أخطأ.
throw new UnauthorizedException('unsigned_request');
}
return true;
}
}
@@ -0,0 +1,10 @@
import { Global, Module } from '@nestjs/common';
import { SigningService } from './signing.service';
import { SignatureGuard } from './signature.guard';
@Global()
@Module({
providers: [SigningService, SignatureGuard],
exports: [SigningService, SignatureGuard],
})
export class SigningModule {}
@@ -0,0 +1,119 @@
import RedisMock from 'ioredis-mock';
import { SigningService } from './signing.service';
const TENANT = 't1';
const USER = 'u1';
const BODY = '{"amount":50,"channel":"cliq"}';
describe('SigningService — توقيع العمليات المالية (docs/17 I6)', () => {
let redis: any;
let signing: SigningService;
const sign = (secret: string, ts: string, method = 'POST', path = '/api/payouts/request', body = BODY) =>
SigningService.sign(secret, SigningService.payload(ts, method, path, body));
const now = () => String(Math.floor(Date.now() / 1000));
beforeEach(async () => {
redis = new RedisMock({ keyPrefix: 'tripz:' });
await redis.flushall();
signing = new SigningService(redis);
});
it('توقيع صحيح يمرّ', async () => {
const secret = await signing.issue(TENANT, USER);
const ts = now();
const sig = sign(secret, ts);
expect(await signing.verify(TENANT, USER, sig, ts, 'POST', '/api/payouts/request', BODY)).toBeNull();
});
it('تبديل المبلغ في الطريق يُكشف', async () => {
const secret = await signing.issue(TENANT, USER);
const ts = now();
const sig = sign(secret, ts); // وُقّع على 50
const tampered = '{"amount":5000,"channel":"cliq"}';
expect(await signing.verify(TENANT, USER, sig, ts, 'POST', '/api/payouts/request', tampered)).toBe(
'bad_signature',
);
});
it('توقيع نقطةٍ لا يصلح لنقطة أخرى', async () => {
const secret = await signing.issue(TENANT, USER);
const ts = now();
const sig = sign(secret, ts, 'POST', '/api/payouts/request');
expect(await signing.verify(TENANT, USER, sig, ts, 'POST', '/api/payouts/x/confirm', BODY)).toBe(
'bad_signature',
);
});
it('طابع زمني قديم يُرفض (منع إعادة البثّ)', async () => {
const secret = await signing.issue(TENANT, USER);
const old = String(Math.floor(Date.now() / 1000) - 3600);
const sig = sign(secret, old);
expect(await signing.verify(TENANT, USER, sig, old, 'POST', '/api/payouts/request', BODY)).toBe(
'stale_timestamp',
);
});
it('طابع زمني غير رقمي يُرفض بلا انهيار', async () => {
const secret = await signing.issue(TENANT, USER);
expect(await signing.verify(TENANT, USER, sign(secret, 'x'), 'x', 'POST', '/p', BODY)).toBe(
'bad_timestamp',
);
});
it('مفتاح مستخدم آخر لا يوقّع نيابةً عنه', async () => {
const secret = await signing.issue(TENANT, USER);
await signing.issue(TENANT, 'attacker');
const ts = now();
const sig = sign(secret, ts);
expect(await signing.verify(TENANT, 'attacker', sig, ts, 'POST', '/api/payouts/request', BODY)).toBe(
'bad_signature',
);
});
it('المستأجرون معزولون', async () => {
const secret = await signing.issue(TENANT, USER);
const ts = now();
expect(await signing.verify('t2', USER, sign(secret, ts), ts, 'POST', '/p', BODY)).toBe(
'no_signing_key',
);
});
it('بلا مفتاح (لم يسجّل دخوله بعد) يُرفض لا يمرّ', async () => {
expect(await signing.verify(TENANT, 'ghost', 'a'.repeat(64), now(), 'POST', '/p', BODY)).toBe(
'no_signing_key',
);
});
it('دخول جديد يُبطل مفتاح الجلسة السابقة', async () => {
const first = await signing.issue(TENANT, USER);
const second = await signing.issue(TENANT, USER);
expect(first).not.toBe(second);
const ts = now();
expect(await signing.verify(TENANT, USER, sign(first, ts), ts, 'POST', '/p', BODY)).toBe(
'bad_signature',
);
});
it('السحب يُبطل التوقيع', async () => {
const secret = await signing.issue(TENANT, USER);
await signing.revoke(TENANT, USER);
const ts = now();
expect(await signing.verify(TENANT, USER, sign(secret, ts), ts, 'POST', '/p', BODY)).toBe(
'no_signing_key',
);
});
it('توقيع بطول مختلف يُرفض بلا انهيار (timingSafeEqual يشترط الطول)', async () => {
const secret = await signing.issue(TENANT, USER);
const ts = now();
expect(await signing.verify(TENANT, USER, 'short', ts, 'POST', '/p', BODY)).toBe('bad_signature');
});
});
@@ -0,0 +1,79 @@
import { Inject, Injectable } from '@nestjs/common';
import { createHmac, randomBytes, timingSafeEqual } from 'crypto';
import Redis from 'ioredis';
import { REDIS } from '../redis/redis.module';
/** عمر مفتاح التوقيع — يطابق عمر توكن التحديث. */
const KEY_TTL_SEC = 30 * 24 * 3600;
/** نافذة انحراف الساعة المسموحة للطابع الزمني. */
export const SIGNATURE_SKEW_SEC = 300;
/**
* توقيع العمليات المالية (docs/17 — I6/D3).
*
* مفتاح **لكل جلسة** يُولَّد على السيرفر عند الدخول — لا سرّ ثابت في التطبيق
* (السرّ الثابت يُستخرج بالهندسة العكسية فيصير التوقيع بلا معنى).
*/
@Injectable()
export class SigningService {
constructor(@Inject(REDIS) private readonly redis: Redis) {}
private key(tenantId: string, userId: string) {
return `sign:${tenantId}:${userId}`;
}
/** يُصدر مفتاحاً جديداً — الدخول من جهاز جديد يُبطل توقيع السابق. */
async issue(tenantId: string, userId: string): Promise<string> {
const secret = randomBytes(32).toString('hex');
await this.redis.set(this.key(tenantId, userId), secret, 'EX', KEY_TTL_SEC);
return secret;
}
async revoke(tenantId: string, userId: string): Promise<void> {
await this.redis.del(this.key(tenantId, userId));
}
/**
* النصّ الموقَّع: `timestamp.METHOD.path.body`.
* تضمين المسار والطريقة يمنع إعادة استعمال توقيعٍ صالح على نقطة أخرى؛
* والجسم يمنع تبديل المبلغ في الطريق (الـAPI على http حالياً).
*/
static payload(timestamp: string, method: string, path: string, rawBody: string): string {
return `${timestamp}.${method.toUpperCase()}.${path}.${rawBody ?? ''}`;
}
static sign(secret: string, payload: string): string {
return createHmac('sha256', secret).update(payload).digest('hex');
}
/**
* يتحقق من التوقيع. يرجع سبب الفشل نصّاً (أو `null` عند النجاح) ليُسجَّل
* في التدقيق — لا ليُعرض للمهاجم.
*/
async verify(
tenantId: string,
userId: string,
signature: string,
timestamp: string,
method: string,
path: string,
rawBody: string,
): Promise<string | null> {
const ts = Number(timestamp);
if (!Number.isFinite(ts)) return 'bad_timestamp';
// نافذة ضيقة = حماية من إعادة البثّ (replay) بلا تخزين حالة.
if (Math.abs(Date.now() / 1000 - ts) > SIGNATURE_SKEW_SEC) return 'stale_timestamp';
const secret = await this.redis.get(this.key(tenantId, userId));
if (!secret) return 'no_signing_key'; // لم يُسجّل دخوله بعد هذه الميزة
const expected = SigningService.sign(secret, SigningService.payload(timestamp, method, path, rawBody));
return SigningService.safeEqual(signature, expected) ? null : 'bad_signature';
}
/** مقارنة ثابتة الزمن — العادية تسرّب التوقيع الصحيح بالتوقيت. */
private static safeEqual(a: string, b: string): boolean {
if (typeof a !== 'string' || a.length !== b.length) return false;
return timingSafeEqual(Buffer.from(a), Buffer.from(b));
}
}
@@ -0,0 +1,65 @@
import { readImageMeta, validateLogo, LOGO_MIN_SIDE } from './image-meta';
/** يبني ترويسة PNG صالحة بأبعاد محدّدة (لا نحتاج بقية الملف للقراءة). */
function png(width: number, height: number): Buffer {
const buf = Buffer.alloc(24);
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(buf, 0);
buf.write('IHDR', 12, 'ascii');
buf.writeUInt32BE(width, 16);
buf.writeUInt32BE(height, 20);
return buf;
}
/** JPEG بجزء APP0 قبل SOF0 — يثبت أننا نتنقّل بالأطوال لا بموضع ثابت. */
function jpeg(width: number, height: number): Buffer {
const app0 = Buffer.alloc(4 + 14);
app0.writeUInt16BE(0xffe0, 0);
app0.writeUInt16BE(16, 2); // طول APP0
const sof = Buffer.alloc(11);
sof.writeUInt16BE(0xffc0, 0);
sof.writeUInt16BE(8, 2);
sof.writeUInt8(8, 4);
sof.writeUInt16BE(height, 5);
sof.writeUInt16BE(width, 7);
return Buffer.concat([Buffer.from([0xff, 0xd8]), app0, sof]);
}
describe('readImageMeta — قراءة الأبعاد بلا اعتمادية', () => {
it('يقرأ أبعاد PNG', () => {
expect(readImageMeta(png(1024, 1024))).toEqual({ format: 'png', width: 1024, height: 1024 });
});
it('يقرأ أبعاد JPEG متجاوزاً الأجزاء التي تسبق SOF', () => {
expect(readImageMeta(jpeg(800, 600))).toEqual({ format: 'jpeg', width: 800, height: 600 });
});
it('يرفض ما ليس صورة', () => {
expect(readImageMeta(Buffer.from('هذا ملف نصي لا صورة'))).toBeNull();
});
it('لا يدور بلا نهاية على JPEG بطول معطوب', () => {
// طول = 0 كان سيُبقي المؤشّر مكانه إلى الأبد.
const bad = Buffer.concat([Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x00]), Buffer.alloc(40)]);
expect(readImageMeta(bad)).toBeNull();
});
});
describe('validateLogo — بوابة جودة الأيقونة (docs/22 N2)', () => {
it('يقبل لوغو مربّعاً كبيراً', () => {
expect(validateLogo(png(1024, 1024))).toBeNull();
});
it('يرفض الصغير — الأيقونة ستخرج مشوّشة', () => {
expect(validateLogo(png(LOGO_MIN_SIDE - 1, LOGO_MIN_SIDE - 1))).toContain('صغير');
});
it('يرفض المستطيل — الأيقونة ستُمطّ', () => {
expect(validateLogo(png(1024, 512))).toContain('غير مربّع');
});
it('يتسامح مع انحراف بسيط عن التربيع', () => {
expect(validateLogo(png(1024, 1000))).toBeNull();
});
});
@@ -0,0 +1,73 @@
/**
* قراءة أبعاد الصورة ونوعها من ترويسة الملف — بلا أي اعتمادية (docs/22 — N2).
*
* لم نُدخل `sharp`: توليد الأيقونات نفسه شغل `flutter_launcher_icons` في
* سكربت N3، والباك إند لا يحتاج إلا **التحقّق** قبل القبول. إدخال مكتبة
* صور أصيلة (libvips) في صورة alpine ثمنٌ كبير مقابل قراءة ٢٤ بايت.
*
* السبب في وجود هذا أصلاً: لوغو صغير أو غير مربّع يُنتج أيقونة ممطوطة أو
* مشوّشة في كل تطبيقات المستأجر — ويُكتشف بعد البناء والنشر لا قبله.
*/
export interface ImageMeta {
format: 'png' | 'jpeg';
width: number;
height: number;
}
/** يعيد null إن لم يكن الملف PNG أو JPEG صالحاً. */
export function readImageMeta(buf: Buffer): ImageMeta | null {
return readPng(buf) ?? readJpeg(buf);
}
function readPng(buf: Buffer): ImageMeta | null {
// توقيع PNG ثم قطعة IHDR: العرض والارتفاع عند 16 و20 (big-endian).
const SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
if (buf.length < 24 || !buf.subarray(0, 8).equals(SIG)) return null;
if (buf.subarray(12, 16).toString('ascii') !== 'IHDR') return null;
return { format: 'png', width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
}
function readJpeg(buf: Buffer): ImageMeta | null {
if (buf.length < 4 || buf[0] !== 0xff || buf[1] !== 0xd8) return null;
// JPEG سلسلة أجزاء متغيّرة الطول؛ الأبعاد في جزء SOF ولا موضع ثابت لها،
// فنتنقّل بالأطوال المعلنة حتى نصل SOFn.
let i = 2;
while (i + 9 < buf.length) {
if (buf[i] !== 0xff) return null; // خرجنا عن المحاذاة = ملف تالف
const marker = buf[i + 1];
const len = buf.readUInt16BE(i + 2);
if (len < 2) return null; // طول معطوب: بلا هذا الفحص تصير الحلقة لانهائية
// SOF0..SOF15 عدا DHT(c4) وJPG(c8) وDAC(cc) — هذه ليست أجزاء إطار.
const isSof = marker >= 0xc0 && marker <= 0xcf &&
marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc;
if (isSof) {
return { format: 'jpeg', height: buf.readUInt16BE(i + 5), width: buf.readUInt16BE(i + 7) };
}
i += 2 + len;
}
return null;
}
/** الحدّ الأدنى لأيقونة تطبيق: أندرويد يحتاج 512 لمتجر Play، وiOS 1024. */
export const LOGO_MIN_SIDE = 512;
/**
* يتحقّق من صلاحية اللوغو كمصدر للأيقونات ويعيد رسالة الخطأ أو null.
* نسمح بانحراف بسيط عن التربيع (٥٪) لأن التصدير اليدوي نادراً ما يكون مضبوطاً
* بالبكسل، لكن شعاراً مستطيلاً فعلاً يجب أن يُرفض لا أن يُمطّ.
*/
export function validateLogo(buf: Buffer): string | null {
const meta = readImageMeta(buf);
if (!meta) return 'الملف ليس PNG أو JPEG صالحاً';
if (meta.width < LOGO_MIN_SIDE || meta.height < LOGO_MIN_SIDE) {
return `اللوغو صغير: ${meta.width}×${meta.height} — الحدّ الأدنى ${LOGO_MIN_SIDE}×${LOGO_MIN_SIDE}`;
}
const ratio = meta.width / meta.height;
if (ratio < 0.95 || ratio > 1.05) {
return `اللوغو غير مربّع: ${meta.width}×${meta.height} — الأيقونة ستُمطّ`;
}
return null;
}
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { StorageService } from './storage.service';
@Global()
@Module({
providers: [StorageService],
exports: [StorageService],
})
export class StorageModule {}
@@ -0,0 +1,46 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { promises as fs } from 'fs';
import { join, dirname, extname } from 'path';
import { randomUUID } from 'crypto';
/**
* مُحوّل تخزين الملفات. محلياً الآن (قرص/فوليوم)، وقابل للتحويل لسيرفر تخزين
* داخل البلد (السيادة الرقمية) بتغيير STORAGE_BASE_URL + STORAGE_DIR (docs/16، docs/06).
* الملفات لكل مستأجر معزولة تحت مجلده.
*/
@Injectable()
export class StorageService {
constructor(private readonly config: ConfigService) {}
private get dir(): string {
return this.config.get<string>('storage.dir') ?? '/app/storage';
}
private get baseUrl(): string {
return this.config.get<string>('storage.baseUrl') ?? '';
}
/** يحفظ ملفاً ويعيد مفتاحه (مسار نسبي معزول بالمستأجر). */
async save(
tenantId: string,
scope: string,
buffer: Buffer,
originalName = '',
): Promise<string> {
const ext = extname(originalName) || '.bin';
const key = `${tenantId}/${scope}/${randomUUID()}${ext}`;
const full = join(this.dir, key);
await fs.mkdir(dirname(full), { recursive: true });
await fs.writeFile(full, buffer);
return key;
}
/** رابط الوصول (يشير لسيرفر التوطين حين يُضبط STORAGE_BASE_URL). */
url(key: string): string {
return this.baseUrl ? `${this.baseUrl}/${key}` : `/storage/${key}`;
}
async read(key: string): Promise<Buffer> {
return fs.readFile(join(this.dir, key));
}
}
@@ -0,0 +1,17 @@
import { AsyncLocalStorage } from 'async_hooks';
export interface TenantStore {
tenantId: string;
userId?: string;
role?: string;
}
/**
* سياق المستأجر لكل طلب — يُملأ من TenantMiddleware ويُقرأ في المستودعات
* لفرض النطاق على tenant_id تلقائياً (راجع docs/06).
*/
export const tenantContext = new AsyncLocalStorage<TenantStore>();
export function currentTenantId(): string | undefined {
return tenantContext.getStore()?.tenantId;
}
@@ -0,0 +1,10 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { currentTenantId } from './tenant.context';
/**
* @Tenant() — يحقن معرّف المستأجر الحالي في معاملات المتحكّم.
*/
export const Tenant = createParamDecorator(
(_data: unknown, _ctx: ExecutionContext): string | undefined =>
currentTenantId(),
);
@@ -0,0 +1,20 @@
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { tenantContext } from './tenant.context';
/**
* يستخرج معرّف المستأجر من الترويسة (أو الـ subdomain لاحقاً) ويضعه في السياق
* لبقية دورة حياة الطلب. لا استعلام يمر بلا tenantId (راجع docs/06).
*/
@Injectable()
export class TenantMiddleware implements NestMiddleware {
use(req: Request, _res: Response, next: NextFunction) {
const headerTenant =
(req.headers['x-tenant-id'] as string) ||
(req.headers['x-tenant'] as string) ||
'';
// السوبر-آدمن قد لا يحمل مستأجراً محدداً — يُعالَج بحارس منفصل لاحقاً.
tenantContext.run({ tenantId: headerTenant }, () => next());
}
}
@@ -0,0 +1,30 @@
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
Logger,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { currentTenantId } from '../tenant/tenant.context';
/**
* يقيس الاستخدام لكل مستأجر (أساس الفوترة الشهرية — راجع docs/05).
* حالياً يسجّل فقط؛ لاحقاً يكتب في جدول usage / يدفع لطابور BullMQ.
*/
@Injectable()
export class UsageInterceptor implements NestInterceptor {
private readonly logger = new Logger('Usage');
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const req = context.switchToHttp().getRequest();
const tenantId = currentTenantId() ?? 'none';
return next.handle().pipe(
tap(() => {
// TODO(P1): عدّ الرحلات المكتملة و GMV بدل تسجيل كل طلب.
this.logger.debug(`tenant=${tenantId} ${req.method} ${req.url}`);
}),
);
}
}
+143
View File
@@ -0,0 +1,143 @@
/**
* إعداد مركزي يقرأ متغيرات البيئة مع افتراضات العزل (راجع docs/14).
*/
export default () => ({
env: process.env.NODE_ENV ?? 'development',
apiPort: parseInt(process.env.API_PORT ?? '4010', 10),
db: {
host: process.env.DB_HOST ?? 'postgres',
port: parseInt(process.env.DB_PORT ?? '5432', 10),
name: process.env.DB_NAME ?? 'tripz',
user: process.env.DB_USER ?? 'tripz',
password: process.env.DB_PASSWORD ?? 'change_me_strong',
// بادئة الجداول لعزل Tripz عن باقي البرامج على نفس السيرفر
tablePrefix: process.env.DB_TABLE_PREFIX ?? 'tripz_',
synchronize: process.env.DB_SYNC === 'true',
},
redis: {
host: process.env.REDIS_HOST ?? 'redis',
port: parseInt(process.env.REDIS_PORT ?? '6379', 10),
// DB رقم غير الافتراضي 0 لعزل Tripz عن باقي البرامج
db: parseInt(process.env.REDIS_DB ?? '3', 10),
keyPrefix: process.env.REDIS_KEY_PREFIX ?? 'tripz:',
},
queue: {
prefix: process.env.QUEUE_PREFIX ?? 'tripz_',
},
jwt: {
secret: process.env.JWT_SECRET ?? 'change_me_jwt_secret',
expires: process.env.JWT_EXPIRES ?? '15m',
refreshExpires: process.env.JWT_REFRESH_EXPIRES ?? '30d',
},
auth: {
/**
* وضع تطوير: رمز OTP ثابت `1234` يُطبع باللوغ بلا مناداة مزوّد (docs/07).
*
* **يفشل مُغلقاً عمداً**: لا يُفتح إلا بـ`OTP_DEV_MODE=true` حرفياً. كان
* `!== 'false'` أي أن غياب المتغيّر — أو خطأً مطبعياً فيه، أو `OTP_DEV_MODE=0`
* (ونصّ `'0'` ليس `'false'`) — يترك الإنتاج برمز ثابت يفتح **كل حساب في
* المنصة**. الافتراض الآمن أن نرسل رسالة حقيقية، لا أن نفتح الباب.
*/
otpDevMode: process.env.OTP_DEV_MODE === 'true',
otpTtl: parseInt(process.env.OTP_TTL ?? '300', 10),
otpLength: parseInt(process.env.OTP_LENGTH ?? '4', 10),
// ربط الجلسة بالجهاز (docs/17 — D2). مطفأ حتى يرسل فلاتر x-device-id؛
// تفعيله قبل ذلك يقطع كل طلب مصادَق (نفس نمط PAYMENTS_REQUIRE_SIGNATURE).
requireDeviceBinding: process.env.AUTH_REQUIRE_DEVICE_BINDING === 'true',
},
maps: {
// بلاطات انطلق تأتي من خوادم انطلق نفسها — لا نستضيفها (docs/25).
tilesUrl: process.env.MAPS_TILES_URL ?? 'https://maps.intaleqapp.com',
provider: process.env.MAPS_PROVIDER ?? 'antlaq',
// خرائط انطلق (map-saas). مفتاح افتراضي + مفاتيح لكل دولة.
baseUrl: process.env.MAPS_BASE_URL ?? 'https://map-saas.intaleqapp.com',
apiKey: process.env.MAPS_API_KEY ?? '',
apiKeyByCountry: {
jo: process.env.MAPS_API_KEY_JO ?? process.env.MAPS_API_KEY ?? '',
sy: process.env.MAPS_API_KEY_SY ?? process.env.MAPS_API_KEY ?? '',
} as Record<string, string>,
placesApiKey: process.env.MAPS_PLACES_API_KEY ?? process.env.MAPS_API_KEY ?? '',
},
// Nabeh — إرسال OTP عبر واتساب.
// توجيه OTP متعدد المزوّدين حسب الدولة (docs/17 — D5).
otp: {
// نصّ رسالة SMS (Kazumi/مصر). {code} يُستبدل بالرمز.
smsTemplate: process.env.OTP_SMS_TEMPLATE ?? 'Your Tripz code is {code}',
kazumi: {
username: process.env.SMS_USERNAME ?? '',
password: process.env.SMS_PASSWORD_EGYPT ?? '',
sender: process.env.SMS_SENDER ?? '',
},
},
// الرحلات: كل كم يفحص كنس الرحلات المجدولة (docs/17 — B5).
trips: {
scheduleSweepMs: parseInt(process.env.TRIP_SCHEDULE_SWEEP_MS ?? '30000', 10),
// R1 — كم يفحص رحلات searching عالقة (10 ثوانٍ كافية: لا ضغط على القاعدة).
searchTimeoutSweepMs: parseInt(process.env.TRIP_SEARCH_TIMEOUT_SWEEP_MS ?? '10000', 10),
// R1 — المهلة قبل إنهاء رحلة searching كـno_drivers (15 دقيقة كسيرو).
searchTimeoutMs: parseInt(process.env.TRIP_SEARCH_TIMEOUT_MS ?? '900000', 10),
// L4 — التسعير الديناميكي: كل كم يعيد حساب المضاعفات (3 دقائق افتراضياً).
surgeSweepMs: parseInt(process.env.TRIP_SURGE_SWEEP_MS ?? '180000', 10),
// L4 — الحدّ الأدنى بين تغييرين متتاليين للمضاعف (5 دقائق).
surgeCooldownMs: parseInt(process.env.TRIP_SURGE_COOLDOWN_MS ?? '300000', 10),
// L4 — حسّاسية الزيادة: 0.1 خفيفة، 0.3 متوسطة، 0.5 حادة.
surgeSensitivity: parseFloat(process.env.TRIP_SURGE_SENSITIVITY ?? '0.3'),
},
// المكافآت: كل كم يمسح الماسح الإحالات المؤهَّلة غير المصروفة (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 ?? '',
password: process.env.NABEH_PASSWORD ?? '',
otpType: process.env.NABEH_OTP_TYPE ?? 'text',
},
// ملاحظة: خرائط الاتصال الدولية صارت في common/phone/phone.service.ts
// (docs/17 — D1) — مصدر واحد للحقيقة بدل نسختين قد تتباعدان.
// Gemini (رؤية) — قراءة الوثائق ومطابقة الوجه.
gemini: {
apiKey: process.env.GEMINI_API_KEY ?? '',
model: process.env.GEMINI_MODEL ?? 'gemini-2.0-flash',
},
// سرّ المنصة (super-admin) — لتعيين أول أدمن قبل وجود أدمن.
platform: {
secret: process.env.PLATFORM_SECRET ?? '',
},
// تخزين ملفات الوثائق. baseUrl فارغ = محلي؛ يُضبط لسيرفر التوطين لاحقاً (docs/06).
storage: {
dir: process.env.STORAGE_DIR ?? '/app/storage',
baseUrl: process.env.STORAGE_BASE_URL ?? '',
},
// خدمة المواصلات (microservice مستقل)
transit: {
serviceUrl: process.env.TRANSIT_SERVICE_URL ?? 'http://transit:4020',
},
// المدفوعات: توقيع HMAC على العمليات المالية (docs/17 — I6).
// مطفأ حتى يوقّع تطبيق فلاتر؛ تفعيله قبل ذلك يقطع كل سحب.
payments: {
requireSignature: process.env.PAYMENTS_REQUIRE_SIGNATURE === 'true',
},
// إشعارات FCM (ملف JSON لحزمة firebase-admin).
fcm: {
credentialsPath: process.env.GOOGLE_APPLICATION_CREDENTIALS ?? '',
},
});
+22
View File
@@ -0,0 +1,22 @@
import 'reflect-metadata';
import { DataSource } from 'typeorm';
import { config as loadEnv } from 'dotenv';
loadEnv();
/**
* مصدر بيانات TypeORM — يُستخدم للهجرات و runtime.
* entityPrefix يضمن أن كل جداول Tripz تبدأ بـ tripz_ لعزلها على السيرفر المشترك.
*/
export const AppDataSource = new DataSource({
type: 'postgres',
host: process.env.DB_HOST ?? 'postgres',
port: parseInt(process.env.DB_PORT ?? '5432', 10),
database: process.env.DB_NAME ?? 'tripz',
username: process.env.DB_USER ?? 'tripz',
password: process.env.DB_PASSWORD ?? 'change_me_strong',
entityPrefix: process.env.DB_TABLE_PREFIX ?? 'tripz_',
synchronize: false,
entities: [__dirname + '/../database/entities/*.entity.{ts,js}'],
migrations: [__dirname + '/../database/migrations/*.{ts,js}'],
});
@@ -0,0 +1,59 @@
import {
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
export type TenantMode = 'shared' | 'sovereign';
export type TenantPlan = 'launch' | 'brand' | 'fleet' | 'sovereign';
/**
* المستأجر = مكتب تكسي / أسطول / مشغّل. الجدول الفعلي: tripz_tenants
* (البادئة من entityPrefix). راجع docs/06 و docs/08.
*/
@Entity('tenants')
export class Tenant {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
name: string;
@Column({ unique: true })
slug: string;
@Column({ name: 'country_pack', default: 'jo' })
countryPack: string;
@Column({ type: 'varchar', default: 'launch' })
plan: TenantPlan;
@Column({ type: 'varchar', default: 'shared' })
mode: TenantMode;
@Column({ type: 'jsonb', default: {} })
branding: Record<string, any>;
// استحقاقات مشتراة (ماذا يملك) — يحكمها السوبر-أدمن. راجع docs/19.
@Column({ type: 'jsonb', default: {} })
features: Record<string, any>;
/**
* سياسات تشغيل المستأجر (كيف يدير عمله) — يحكمها **المستأجر نفسه**، لا
* السوبر-أدمن. منفصلة عن `features` عمداً: تلك «ماذا اشتريت»، وهذه «كيف
* تشتغل». راجع docs/18 §5.2 (سياسة الدين).
*/
@Column({ type: 'jsonb', default: {} })
settings: Record<string, any>;
@Column({ default: 'active' })
status: string;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}
@@ -0,0 +1,32 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* أول هجرة: جدول المستأجرين + تفعيل PostGIS.
* اسم الجدول الفعلي tripz_tenants (البادئة تُضاف عبر entityPrefix، لكن الهجرة
* تكتب الاسم صراحةً لأنها SQL خام).
*/
export class InitTenants1721145600000 implements MigrationInterface {
public async up(q: QueryRunner): Promise<void> {
await q.query(`CREATE EXTENSION IF NOT EXISTS postgis`);
await q.query(`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`);
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_tenants (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
name varchar NOT NULL,
slug varchar NOT NULL UNIQUE,
country_pack varchar NOT NULL DEFAULT 'jo',
plan varchar NOT NULL DEFAULT 'launch',
mode varchar NOT NULL DEFAULT 'shared',
branding jsonb NOT NULL DEFAULT '{}',
features jsonb NOT NULL DEFAULT '{}',
status varchar NOT NULL DEFAULT 'active',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
)
`);
}
public async down(q: QueryRunner): Promise<void> {
await q.query(`DROP TABLE IF EXISTS tripz_tenants`);
}
}
@@ -0,0 +1,29 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* جدول المستخدمين tripz_users + فهرس فريد (tenant_id, phone).
*/
export class InitUsers1721232000000 implements MigrationInterface {
public async up(q: QueryRunner): Promise<void> {
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_users (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id uuid NOT NULL,
phone varchar NOT NULL,
name varchar,
role varchar NOT NULL DEFAULT 'rider',
status varchar NOT NULL DEFAULT 'active',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
)
`);
await q.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_tripz_users_tenant_phone"
ON tripz_users (tenant_id, phone)
`);
}
public async down(q: QueryRunner): Promise<void> {
await q.query(`DROP TABLE IF EXISTS tripz_users`);
}
}
@@ -0,0 +1,87 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* جداول P1: drivers, tariffs, trips, trip_events (كلها ببادئة tripz_).
*/
export class InitP11721300000000 implements MigrationInterface {
public async up(q: QueryRunner): Promise<void> {
// drivers
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_drivers (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id uuid NOT NULL,
user_id uuid NOT NULL,
service_class varchar NOT NULL DEFAULT 'economy',
vehicle_make varchar, vehicle_model varchar,
vehicle_plate varchar, vehicle_color varchar,
docs jsonb NOT NULL DEFAULT '{}',
verification_status varchar NOT NULL DEFAULT 'pending',
is_online boolean NOT NULL DEFAULT false,
rating numeric(3,2) NOT NULL DEFAULT 5,
last_lat double precision, last_lng double precision,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
)`);
await q.query(`CREATE UNIQUE INDEX IF NOT EXISTS "UQ_tripz_drivers_tenant_user" ON tripz_drivers (tenant_id, user_id)`);
// tariffs
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_tariffs (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id uuid NOT NULL,
city varchar NOT NULL,
service_class varchar NOT NULL,
definition jsonb NOT NULL,
version int NOT NULL DEFAULT 1,
active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now()
)`);
await q.query(`CREATE INDEX IF NOT EXISTS "IDX_tripz_tariffs_lookup" ON tripz_tariffs (tenant_id, city, service_class, active)`);
// trips
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_trips (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id uuid NOT NULL,
rider_id uuid NOT NULL,
driver_id uuid,
service_class varchar NOT NULL DEFAULT 'economy',
city varchar,
origin_lat double precision NOT NULL, origin_lng double precision NOT NULL,
dest_lat double precision NOT NULL, dest_lng double precision NOT NULL,
status varchar NOT NULL DEFAULT 'searching',
distance_km numeric(8,3), duration_min numeric(8,1),
tariff_id uuid, tariff_version int,
quoted_fare numeric(12,3), final_fare numeric(12,3),
currency varchar,
payment_method varchar NOT NULL DEFAULT 'cash',
requested_at timestamptz NOT NULL DEFAULT now(),
assigned_at timestamptz, completed_at timestamptz,
updated_at timestamptz NOT NULL DEFAULT now()
)`);
await q.query(`CREATE INDEX IF NOT EXISTS "IDX_tripz_trips_status" ON tripz_trips (tenant_id, status)`);
await q.query(`CREATE INDEX IF NOT EXISTS "IDX_tripz_trips_rider" ON tripz_trips (tenant_id, rider_id)`);
await q.query(`CREATE INDEX IF NOT EXISTS "IDX_tripz_trips_driver" ON tripz_trips (tenant_id, driver_id)`);
// trip_events
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_trip_events (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id uuid NOT NULL,
trip_id uuid NOT NULL,
from_status varchar,
to_status varchar NOT NULL,
source varchar NOT NULL DEFAULT 'system',
payload jsonb NOT NULL DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now()
)`);
await q.query(`CREATE INDEX IF NOT EXISTS "IDX_tripz_trip_events_trip" ON tripz_trip_events (tenant_id, trip_id)`);
}
public async down(q: QueryRunner): Promise<void> {
await q.query(`DROP TABLE IF EXISTS tripz_trip_events`);
await q.query(`DROP TABLE IF EXISTS tripz_trips`);
await q.query(`DROP TABLE IF EXISTS tripz_tariffs`);
await q.query(`DROP TABLE IF EXISTS tripz_drivers`);
}
}
@@ -0,0 +1,59 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* P1b: chat_messages, ratings, fraud_flags + أعمدة إلغاء على trips.
*/
export class InitP1b1721400000000 implements MigrationInterface {
public async up(q: QueryRunner): Promise<void> {
await q.query(`ALTER TABLE tripz_trips ADD COLUMN IF NOT EXISTS cancelled_by varchar`);
await q.query(`ALTER TABLE tripz_trips ADD COLUMN IF NOT EXISTS cancel_fee numeric(12,3)`);
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_chat_messages (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id uuid NOT NULL,
trip_id uuid NOT NULL,
sender_id uuid NOT NULL,
sender_role varchar NOT NULL,
body text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
)`);
await q.query(`CREATE INDEX IF NOT EXISTS "IDX_tripz_chat_trip" ON tripz_chat_messages (tenant_id, trip_id)`);
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_ratings (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id uuid NOT NULL,
trip_id uuid NOT NULL,
by_user_id uuid NOT NULL,
by_role varchar NOT NULL,
target_driver_id uuid,
stars int NOT NULL,
comment text,
created_at timestamptz NOT NULL DEFAULT now()
)`);
await q.query(`CREATE INDEX IF NOT EXISTS "IDX_tripz_ratings_trip" ON tripz_ratings (tenant_id, trip_id)`);
await q.query(`CREATE INDEX IF NOT EXISTS "IDX_tripz_ratings_driver" ON tripz_ratings (tenant_id, target_driver_id)`);
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_fraud_flags (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id uuid NOT NULL,
subject_type varchar NOT NULL,
subject_id uuid NOT NULL,
trip_id uuid,
reason varchar NOT NULL,
meta jsonb NOT NULL DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now()
)`);
await q.query(`CREATE INDEX IF NOT EXISTS "IDX_tripz_fraud_subject" ON tripz_fraud_flags (tenant_id, subject_type, subject_id)`);
}
public async down(q: QueryRunner): Promise<void> {
await q.query(`DROP TABLE IF EXISTS tripz_fraud_flags`);
await q.query(`DROP TABLE IF EXISTS tripz_ratings`);
await q.query(`DROP TABLE IF EXISTS tripz_chat_messages`);
await q.query(`ALTER TABLE tripz_trips DROP COLUMN IF EXISTS cancel_fee`);
await q.query(`ALTER TABLE tripz_trips DROP COLUMN IF EXISTS cancelled_by`);
}
}
@@ -0,0 +1,71 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* P2: ride_types, wallets, wallet_txns, device_tokens + trips.is_round_trip.
*/
export class InitP21721500000000 implements MigrationInterface {
public async up(q: QueryRunner): Promise<void> {
await q.query(`ALTER TABLE tripz_trips ADD COLUMN IF NOT EXISTS is_round_trip boolean NOT NULL DEFAULT false`);
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_ride_types (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id uuid NOT NULL,
code varchar NOT NULL,
name_ar varchar NOT NULL,
name_en varchar,
vehicle_kind varchar NOT NULL DEFAULT 'car',
women_only boolean NOT NULL DEFAULT false,
round_trip_supported boolean NOT NULL DEFAULT true,
icon varchar,
sort int NOT NULL DEFAULT 0,
active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now()
)`);
await q.query(`CREATE UNIQUE INDEX IF NOT EXISTS "UQ_tripz_ride_types_code" ON tripz_ride_types (tenant_id, code)`);
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_wallets (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id uuid NOT NULL,
user_id uuid NOT NULL,
balance numeric(12,3) NOT NULL DEFAULT 0,
currency varchar NOT NULL DEFAULT 'JOD',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
)`);
await q.query(`CREATE UNIQUE INDEX IF NOT EXISTS "UQ_tripz_wallets_user" ON tripz_wallets (tenant_id, user_id)`);
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_wallet_txns (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id uuid NOT NULL,
wallet_id uuid NOT NULL,
amount numeric(12,3) NOT NULL,
type varchar NOT NULL,
reason varchar NOT NULL,
ref varchar,
created_at timestamptz NOT NULL DEFAULT now()
)`);
await q.query(`CREATE INDEX IF NOT EXISTS "IDX_tripz_wallet_txns_wallet" ON tripz_wallet_txns (tenant_id, wallet_id)`);
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_device_tokens (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id uuid NOT NULL,
user_id uuid NOT NULL,
token varchar NOT NULL UNIQUE,
platform varchar NOT NULL DEFAULT 'android',
created_at timestamptz NOT NULL DEFAULT now()
)`);
await q.query(`CREATE INDEX IF NOT EXISTS "IDX_tripz_device_tokens_user" ON tripz_device_tokens (tenant_id, user_id)`);
}
public async down(q: QueryRunner): Promise<void> {
await q.query(`DROP TABLE IF EXISTS tripz_device_tokens`);
await q.query(`DROP TABLE IF EXISTS tripz_wallet_txns`);
await q.query(`DROP TABLE IF EXISTS tripz_wallets`);
await q.query(`DROP TABLE IF EXISTS tripz_ride_types`);
await q.query(`ALTER TABLE tripz_trips DROP COLUMN IF EXISTS is_round_trip`);
}
}
@@ -0,0 +1,50 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* جداول الدفع والسحب (schema الدفع منطقياً — بادئة tripz_pay_، قابلة للفصل).
*/
export class InitPayments1721600000000 implements MigrationInterface {
public async up(q: QueryRunner): Promise<void> {
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_pay_payments (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id uuid NOT NULL,
user_id uuid NOT NULL,
trip_id uuid,
purpose varchar NOT NULL DEFAULT 'topup',
provider varchar NOT NULL,
method varchar,
amount numeric(12,3) NOT NULL,
currency varchar NOT NULL DEFAULT 'JOD',
status varchar NOT NULL DEFAULT 'pending',
tx_ref varchar,
redirect_url varchar,
meta jsonb NOT NULL DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
)`);
await q.query(`CREATE INDEX IF NOT EXISTS "IDX_tripz_pay_payments_user" ON tripz_pay_payments (tenant_id, user_id)`);
await q.query(`CREATE INDEX IF NOT EXISTS "IDX_tripz_pay_payments_status" ON tripz_pay_payments (tenant_id, status)`);
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_pay_payouts (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id uuid NOT NULL,
driver_user_id uuid NOT NULL,
amount numeric(12,3) NOT NULL,
currency varchar NOT NULL DEFAULT 'JOD',
channel varchar NOT NULL,
destination jsonb NOT NULL DEFAULT '{}',
status varchar NOT NULL DEFAULT 'requested',
ref varchar,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
)`);
await q.query(`CREATE INDEX IF NOT EXISTS "IDX_tripz_pay_payouts_driver" ON tripz_pay_payouts (tenant_id, driver_user_id)`);
}
public async down(q: QueryRunner): Promise<void> {
await q.query(`DROP TABLE IF EXISTS tripz_pay_payouts`);
await q.query(`DROP TABLE IF EXISTS tripz_pay_payments`);
}
}
@@ -0,0 +1,28 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/** وثائق السائق. الجدول: tripz_driver_documents. */
export class InitDocuments1721700000000 implements MigrationInterface {
public async up(q: QueryRunner): Promise<void> {
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_driver_documents (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id uuid NOT NULL,
driver_id uuid NOT NULL,
type varchar NOT NULL,
file_key varchar NOT NULL,
number varchar,
expiry_date date,
status varchar NOT NULL DEFAULT 'pending',
review_note varchar,
reviewed_by uuid,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
)`);
await q.query(`CREATE INDEX IF NOT EXISTS "IDX_tripz_driver_documents_driver" ON tripz_driver_documents (tenant_id, driver_id)`);
await q.query(`CREATE INDEX IF NOT EXISTS "IDX_tripz_driver_documents_status" ON tripz_driver_documents (tenant_id, status)`);
}
public async down(q: QueryRunner): Promise<void> {
await q.query(`DROP TABLE IF EXISTS tripz_driver_documents`);
}
}
@@ -0,0 +1,18 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/** حقول إضافية للوثائق: الوجه، اسم الحامل، تاريخ الإصدار، حقول إضافية. */
export class DocumentsFields1721750000000 implements MigrationInterface {
public async up(q: QueryRunner): Promise<void> {
await q.query(`ALTER TABLE tripz_driver_documents ADD COLUMN IF NOT EXISTS side varchar NOT NULL DEFAULT 'single'`);
await q.query(`ALTER TABLE tripz_driver_documents ADD COLUMN IF NOT EXISTS holder_name varchar`);
await q.query(`ALTER TABLE tripz_driver_documents ADD COLUMN IF NOT EXISTS issue_date date`);
await q.query(`ALTER TABLE tripz_driver_documents ADD COLUMN IF NOT EXISTS extra jsonb NOT NULL DEFAULT '{}'`);
}
public async down(q: QueryRunner): Promise<void> {
await q.query(`ALTER TABLE tripz_driver_documents DROP COLUMN IF EXISTS extra`);
await q.query(`ALTER TABLE tripz_driver_documents DROP COLUMN IF EXISTS issue_date`);
await q.query(`ALTER TABLE tripz_driver_documents DROP COLUMN IF EXISTS holder_name`);
await q.query(`ALTER TABLE tripz_driver_documents DROP COLUMN IF EXISTS side`);
}
}
@@ -0,0 +1,12 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/** لغة المستخدم — تُستخدم لترجمة الإشعارات (docs/17 — A2). */
export class UserLanguage1721800000000 implements MigrationInterface {
public async up(q: QueryRunner): Promise<void> {
await q.query(`ALTER TABLE tripz_users ADD COLUMN IF NOT EXISTS language varchar NOT NULL DEFAULT 'ar'`);
}
public async down(q: QueryRunner): Promise<void> {
await q.query(`ALTER TABLE tripz_users DROP COLUMN IF EXISTS language`);
}
}
@@ -0,0 +1,28 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* دفتر المحفظة (docs/17 — I1):
* - `balance_after` لتدقيق كل حركة ومطابقتها بالرصيد.
* - قيد `balance >= 0` — شبكة أمان أخيرة على مستوى القاعدة: أي مسار كتابة
* يتجاوز الخصم الذرّي سيفشل بدل أن يُنتج رصيداً سالباً بصمت.
*/
export class WalletLedger1721810000000 implements MigrationInterface {
public async up(q: QueryRunner): Promise<void> {
await q.query(
`ALTER TABLE tripz_wallet_txns ADD COLUMN IF NOT EXISTS balance_after numeric(12,3)`,
);
await q.query(`
DO $$ BEGIN
ALTER TABLE tripz_wallets ADD CONSTRAINT tripz_wallets_balance_non_negative CHECK (balance >= 0);
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
`);
}
public async down(q: QueryRunner): Promise<void> {
await q.query(
`ALTER TABLE tripz_wallets DROP CONSTRAINT IF EXISTS tripz_wallets_balance_non_negative`,
);
await q.query(`ALTER TABLE tripz_wallet_txns DROP COLUMN IF EXISTS balance_after`);
}
}
@@ -0,0 +1,25 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* تقييم الطرفين (docs/17 — G6):
* - `ratings.target_user_id` — الراكب المُقيَّم عندما يقيّمه السائق. بدونه كان
* تقييم السائق للراكب يُخزَّن بلا هدف فلا يُجمَّع أبداً.
* - `users.rating` — التقييم الظاهر للراكب، يُكتب مرة واحدة يومياً.
*/
export class RatingsBothParties1721820000000 implements MigrationInterface {
public async up(q: QueryRunner): Promise<void> {
await q.query(`ALTER TABLE tripz_ratings ADD COLUMN IF NOT EXISTS target_user_id uuid`);
await q.query(
`CREATE INDEX IF NOT EXISTS "IDX_tripz_ratings_tenant_target_user" ON tripz_ratings (tenant_id, target_user_id)`,
);
await q.query(
`ALTER TABLE tripz_users ADD COLUMN IF NOT EXISTS rating numeric(3,2) NOT NULL DEFAULT 5`,
);
}
public async down(q: QueryRunner): Promise<void> {
await q.query(`ALTER TABLE tripz_users DROP COLUMN IF EXISTS rating`);
await q.query(`DROP INDEX IF EXISTS "IDX_tripz_ratings_tenant_target_user"`);
await q.query(`ALTER TABLE tripz_ratings DROP COLUMN IF EXISTS target_user_id`);
}
}
@@ -0,0 +1,41 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* المواقع (docs/17 — H3/H4):
* - أعمدة لقطة الموقع على `drivers` — يكتبها الـworker دورياً، لا كل نبضة.
* - `driver_tracks` — نقاط المسار التاريخية، إدراج مجمّع من الـworker.
*/
export class DriverLocations1721830000000 implements MigrationInterface {
public async up(q: QueryRunner): Promise<void> {
await q.query(`ALTER TABLE tripz_drivers ADD COLUMN IF NOT EXISTS heading real`);
await q.query(`ALTER TABLE tripz_drivers ADD COLUMN IF NOT EXISTS speed real`);
await q.query(`ALTER TABLE tripz_drivers ADD COLUMN IF NOT EXISTS loc_status varchar`);
await q.query(`ALTER TABLE tripz_drivers ADD COLUMN IF NOT EXISTS loc_updated_at timestamptz`);
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_driver_tracks (
id bigserial PRIMARY KEY,
tenant_id uuid NOT NULL,
driver_id uuid NOT NULL,
lat double precision NOT NULL,
lng double precision NOT NULL,
heading real,
speed real,
status varchar,
recorded_at timestamptz NOT NULL
)
`);
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 TABLE IF EXISTS tripz_driver_tracks`);
await q.query(`ALTER TABLE tripz_drivers DROP COLUMN IF EXISTS loc_updated_at`);
await q.query(`ALTER TABLE tripz_drivers DROP COLUMN IF EXISTS loc_status`);
await q.query(`ALTER TABLE tripz_drivers DROP COLUMN IF EXISTS speed`);
await q.query(`ALTER TABLE tripz_drivers DROP COLUMN IF EXISTS heading`);
}
}
@@ -0,0 +1,52 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* نموذج الرحلة (docs/17 — B2/B3/B6/B7):
* - فصل السعر: ما يدفعه الراكب مقابل ما يقبضه السائق + العمولة.
* - الانتظار: الدقائق ورسمها.
* - مشوار وصول السائق للراكب: مسافته وزمنه ورسمه.
* - طوابع دقيقة: انطلاق نحو الراكب · الوصول · بدء الرحلة.
*/
export class TripPricingAndTimestamps1721840000000 implements MigrationInterface {
public async up(q: QueryRunner): Promise<void> {
const add = (col: string, type: string) =>
q.query(`ALTER TABLE tripz_trips ADD COLUMN IF NOT EXISTS ${col} ${type}`);
await add('price_for_passenger', 'numeric(12,3)');
await add('price_for_driver', 'numeric(12,3)');
await add('commission_amount', 'numeric(12,3)');
await add('commission_rate', 'numeric(5,2)');
await add('waiting_min', 'numeric(6,1)');
await add('waiting_charge', 'numeric(12,3)');
await add('pickup_distance_km', 'numeric(8,3)');
await add('pickup_duration_min', 'numeric(8,1)');
await add('pickup_charge', 'numeric(12,3)');
await add('driver_going_at', 'timestamptz');
await add('arrived_at', 'timestamptz');
await add('started_at', 'timestamptz');
// رحلات قائمة: الراكب دفع = السائق قبض (لم تكن هناك عمولة أصلاً).
await q.query(`
UPDATE tripz_trips
SET price_for_passenger = COALESCE(price_for_passenger, final_fare, quoted_fare),
price_for_driver = COALESCE(price_for_driver, final_fare, quoted_fare)
WHERE quoted_fare IS NOT NULL OR final_fare IS NOT NULL
`);
}
public async down(q: QueryRunner): Promise<void> {
const drop = (col: string) =>
q.query(`ALTER TABLE tripz_trips DROP COLUMN IF EXISTS ${col}`);
for (const c of [
'started_at', 'arrived_at', 'driver_going_at',
'pickup_charge', 'pickup_duration_min', 'pickup_distance_km',
'waiting_charge', 'waiting_min',
'commission_rate', 'commission_amount', 'price_for_driver', 'price_for_passenger',
]) {
await drop(c);
}
}
}
@@ -0,0 +1,59 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* الرصيد التشغيلي للسائق (docs/18 — المجموعة J).
* عمولة مدفوعة سلفاً، منفصلة تماماً عن محفظة أرباحه.
*
* **بلا قيد `balance >= 0`** — بخلاف `tripz_wallets`: الدين مسموح عمداً
* (قرار المالك: الرحلة لا تُقطع أبداً ولو صار الرصيد سالباً).
*/
export class DriverCredit1721850000000 implements MigrationInterface {
public async up(q: QueryRunner): Promise<void> {
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_driver_credits (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id uuid NOT NULL,
driver_id uuid NOT NULL,
balance numeric(12,3) NOT NULL DEFAULT 0,
currency varchar NOT NULL DEFAULT 'JOD',
created_at TIMESTAMP NOT NULL DEFAULT now(),
updated_at TIMESTAMP NOT NULL DEFAULT now()
)
`);
await q.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "IDX_tripz_driver_credits_tenant_driver"
ON tripz_driver_credits (tenant_id, driver_id)
`);
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_credit_txns (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id uuid NOT NULL,
driver_id uuid NOT NULL,
amount numeric(12,3) NOT NULL,
type varchar NOT NULL,
balance_after numeric(12,3),
trip_id uuid,
ref varchar,
created_at TIMESTAMP NOT NULL DEFAULT now()
)
`);
await q.query(`
CREATE INDEX IF NOT EXISTS "IDX_tripz_credit_txns_driver_time"
ON tripz_credit_txns (tenant_id, driver_id, created_at)
`);
// مكافأة التسجيل مرة واحدة لكل سائق — تُفرض على القاعدة لا بفحص تطبيقي،
// فتسجيلان متزامنان لا يمنحانها مرتين (docs/18 — J6).
await q.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "IDX_tripz_credit_txns_signup_once"
ON tripz_credit_txns (tenant_id, driver_id)
WHERE type = 'signup_bonus'
`);
}
public async down(q: QueryRunner): Promise<void> {
await q.query(`DROP TABLE IF EXISTS tripz_credit_txns`);
await q.query(`DROP TABLE IF EXISTS tripz_driver_credits`);
}
}
@@ -0,0 +1,19 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* سياسات تشغيل المستأجر (docs/18 §5.2): سياسة الدين ومكافأة التسجيل لكل عملة.
*
* منفصلة عن `features` عمداً: تلك استحقاقات مشتراة يحكمها السوبر-أدمن، وهذه
* سياسات يحكمها المستأجر نفسه.
*/
export class TenantSettings1721860000000 implements MigrationInterface {
public async up(q: QueryRunner): Promise<void> {
await q.query(
`ALTER TABLE tripz_tenants ADD COLUMN IF NOT EXISTS settings jsonb NOT NULL DEFAULT '{}'`,
);
}
public async down(q: QueryRunner): Promise<void> {
await q.query(`ALTER TABLE tripz_tenants DROP COLUMN IF EXISTS settings`);
}
}
@@ -0,0 +1,63 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* أمان السحب + سجل التدقيق (docs/17 — I4/I5/I7).
* - أعمدة إثبات هوية السحب على `pay_payouts`.
* - `audit_log` — من فعل ماذا ومتى ومن أين (append-only).
*/
export class PayoutSecurityAndAudit1721870000000 implements MigrationInterface {
public async up(q: QueryRunner): Promise<void> {
const add = (col: string, type: string) =>
q.query(`ALTER TABLE tripz_pay_payouts ADD COLUMN IF NOT EXISTS ${col} ${type}`);
await add('otp_verified_at', 'timestamptz');
await add('biometric_method', 'varchar');
await add('biometric_at', 'timestamptz');
await add('device_id', 'varchar');
await add('request_ip', 'varchar');
// الافتراض الجديد للطلبات الجديدة: لا شيء يتحرك قبل التأكيد.
await q.query(`ALTER TABLE tripz_pay_payouts ALTER COLUMN status SET DEFAULT 'pending_otp'`);
// السحوبات القائمة أُنشئت قبل وجود OTP وقد حُجز مالها فعلاً — تُعتبر
// مُتحقَّقة، وإلا رفض `complete` صرفها إلى الأبد.
await q.query(`
UPDATE tripz_pay_payouts
SET otp_verified_at = COALESCE(otp_verified_at, created_at)
WHERE status IN ('requested', 'processing', 'paid')
`);
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_audit_log (
id bigserial PRIMARY KEY,
tenant_id uuid NOT NULL,
actor_user_id uuid,
actor_role varchar,
action varchar NOT NULL,
subject_type varchar,
subject_id varchar,
amount numeric(12,3),
currency varchar,
meta jsonb NOT NULL DEFAULT '{}',
ip varchar,
created_at TIMESTAMP NOT NULL DEFAULT now()
)
`);
await q.query(`
CREATE INDEX IF NOT EXISTS "IDX_tripz_audit_log_tenant_time"
ON tripz_audit_log (tenant_id, created_at)
`);
await q.query(`
CREATE INDEX IF NOT EXISTS "IDX_tripz_audit_log_subject"
ON tripz_audit_log (tenant_id, subject_type, subject_id)
`);
}
public async down(q: QueryRunner): Promise<void> {
await q.query(`DROP TABLE IF EXISTS tripz_audit_log`);
await q.query(`ALTER TABLE tripz_pay_payouts ALTER COLUMN status SET DEFAULT 'requested'`);
for (const c of ['request_ip', 'device_id', 'biometric_at', 'biometric_method', 'otp_verified_at']) {
await q.query(`ALTER TABLE tripz_pay_payouts DROP COLUMN IF EXISTS ${c}`);
}
}
}
@@ -0,0 +1,50 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* تطبيع أرقام الهاتف المخزَّنة مسبقاً (docs/17 — D1).
*
* قبل هذه الهجرة كانت `sendOtp`/`verifyOtp` تخزّنان الرقم **كما كُتب حرفياً**
* — فحسابات اختبار الحمل (`0797…`) وأي دخول سابق مخزَّنة بصيغة محلية لا
* دولية. تطبّق هذه الهجرة نفس منطق `PhoneService` بـSQL خام لكل مستأجر حسب
* `country_pack`، وتتخطّى أي صفٍّ يتعارض ناتجه مع صفٍّ آخر (لا تكسر القيد
* الفريد `(tenant_id, phone)`؛ التعارض النادر يُترك يدوياً).
*/
export class NormalizePhones1721880000000 implements MigrationInterface {
public async up(q: QueryRunner): Promise<void> {
// jo/sy: صفر محلي + 9 أرقام → مفتاح الدولة + 9 أرقام.
await q.query(`
UPDATE tripz_users u
SET phone = t.country_pack_cc || substring(u.phone from 2)
FROM (
SELECT id, CASE country_pack WHEN 'jo' THEN '962' WHEN 'sy' THEN '963' END AS country_pack_cc
FROM tripz_tenants WHERE country_pack IN ('jo', 'sy')
) t
WHERE u.tenant_id = t.id
AND u.phone ~ '^0[0-9]{9}$'
AND NOT EXISTS (
SELECT 1 FROM tripz_users u2
WHERE u2.tenant_id = u.tenant_id
AND u2.phone = t.country_pack_cc || substring(u.phone from 2)
)
`);
// eg: صفر محلي + 10 أرقام → "20" + 10 أرقام.
await q.query(`
UPDATE tripz_users u
SET phone = '20' || substring(u.phone from 2)
FROM tripz_tenants t
WHERE u.tenant_id = t.id
AND t.country_pack = 'eg'
AND u.phone ~ '^0[0-9]{10}$'
AND NOT EXISTS (
SELECT 1 FROM tripz_users u2
WHERE u2.tenant_id = u.tenant_id AND u2.phone = '20' || substring(u.phone from 2)
)
`);
}
public async down(): Promise<void> {
// لا رجوع — لا سبيل لمعرفة الصيغة الأصلية بعد التطبيع (وقد تكون كانت
// بصيغ مختلفة أصلاً). التطبيع أُحادي الاتجاه بطبيعته.
}
}
@@ -0,0 +1,26 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* إشارة المكالمة على الرحلة (docs/17 — E1/E2؛ مكافئ isDriverCallPassenger
* عند سيرو). تُخزَّن دائماً على الرحلة نفسها — لا في Redis فقط — لأنها
* حقيقة تفيد التحقيق حتى بعد زوال حالة الرحلة من الكاش.
*/
export class TripCallSignal1721890000000 implements MigrationInterface {
public async up(q: QueryRunner): Promise<void> {
await q.query(
`ALTER TABLE tripz_trips ADD COLUMN IF NOT EXISTS driver_called_passenger boolean NOT NULL DEFAULT false`,
);
await q.query(`ALTER TABLE tripz_trips ADD COLUMN IF NOT EXISTS last_call_at timestamptz`);
await q.query(`ALTER TABLE tripz_trips ADD COLUMN IF NOT EXISTS last_call_by varchar`);
await q.query(
`ALTER TABLE tripz_trips ADD COLUMN IF NOT EXISTS call_count integer NOT NULL DEFAULT 0`,
);
}
public async down(q: QueryRunner): Promise<void> {
await q.query(`ALTER TABLE tripz_trips DROP COLUMN IF EXISTS call_count`);
await q.query(`ALTER TABLE tripz_trips DROP COLUMN IF EXISTS last_call_by`);
await q.query(`ALTER TABLE tripz_trips DROP COLUMN IF EXISTS last_call_at`);
await q.query(`ALTER TABLE tripz_trips DROP COLUMN IF EXISTS driver_called_passenger`);
}
}
@@ -0,0 +1,56 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import { encrypt, blindIndex } from '../../common/crypto/crypto.util';
/**
* تشفير الهاتف at-rest + فهرس أعمى للبحث (docs/16، docs/17 — D1 إضافة).
*
* **لماذا فهرس منفصل**: `phone` بعد التشفير غير قابل للبحث بالمساواة إطلاقاً
* — AES-256-GCM يستعمل IV عشوائياً لكل عملية، فنفس الرقم الحقيقي ينتج شيفرة
* مختلفة كل مرة. القيد الفريد ونقاط `WHERE phone = …` (تسجيل الدخول بأكمله)
* ينتقلان إلى `phone_bidx` — بصمة حتمية (HMAC) لا تكشف الرقم لكنها تكرّر
* نفسها لنفس الرقم دائماً.
*
* تُشفَّر الصفوف القائمة في نفس الهجرة (لا تُترك نصّاً صافياً) — الحماية
* التلقائية عبر `decrypt()` المتسامح كانت ستُبقيها صافية للأبد بلا هذا.
*/
export class EncryptPhone1721900000000 implements MigrationInterface {
public async up(q: QueryRunner): Promise<void> {
await q.query(`ALTER TABLE tripz_users ADD COLUMN IF NOT EXISTS phone_bidx varchar`);
const rows: { id: string; phone: string }[] = await q.query(
`SELECT id, phone FROM tripz_users`,
);
for (const row of rows) {
// تسامح إعادة التشغيل الجزئي: لا نُشفّر قيمة مشفّرة أصلاً مرة أخرى.
const alreadyEncrypted = row.phone?.startsWith('v1:');
const canonicalPhone = alreadyEncrypted ? null : row.phone;
const bidx = blindIndex(canonicalPhone ?? row.phone);
if (alreadyEncrypted) {
await q.query(`UPDATE tripz_users SET phone_bidx = $1 WHERE id = $2`, [bidx, row.id]);
} else {
await q.query(`UPDATE tripz_users SET phone = $1, phone_bidx = $2 WHERE id = $3`, [
encrypt(row.phone),
bidx,
row.id,
]);
}
}
await q.query(`ALTER TABLE tripz_users ALTER COLUMN phone_bidx SET NOT NULL`);
await q.query(`DROP INDEX IF EXISTS "UQ_tripz_users_tenant_phone"`);
await q.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_tripz_users_tenant_phone_bidx"
ON tripz_users (tenant_id, phone_bidx)
`);
}
public async down(q: QueryRunner): Promise<void> {
// لا رجوع لفكّ التشفير جماعياً هنا عمداً — decrypt() يبقى متاحاً عند
// القراءة عبر الـORM؛ التراجع عن هذه الهجرة يعني قبول عدم قابلية البحث
// إن أُعيد phone فهرساً فريداً وحده، فلا نعيد بناء ذلك القيد تلقائياً.
await q.query(`DROP INDEX IF EXISTS "UQ_tripz_users_tenant_phone_bidx"`);
await q.query(`ALTER TABLE tripz_users ALTER COLUMN phone_bidx DROP NOT NULL`);
await q.query(`ALTER TABLE tripz_users DROP COLUMN IF EXISTS phone_bidx`);
}
}
@@ -0,0 +1,76 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* بيانات السائق والمركبة (docs/17 — المجموعة C):
* - جدول `vehicles` (مكافئ CarRegistration عند سيرو) — سائق قد يملك أكثر من مركبة.
* - حقول الملف الشخصي للسائق: الرقم الوطني (مشفّر + فهرس أعمى)، الرخصة،
* ai_data/user_input، سبب الرفض.
*/
export class VehiclesAndDriverProfile1721910000000 implements MigrationInterface {
public async up(q: QueryRunner): Promise<void> {
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_vehicles (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id uuid NOT NULL,
driver_id uuid NOT NULL,
plate varchar,
vin varchar,
make varchar,
model varchar,
year integer,
color varchar,
color_hex varchar,
fuel varchar,
owner varchar,
registration_expiry date,
category varchar,
is_default boolean NOT NULL DEFAULT false,
status varchar NOT NULL DEFAULT 'pending',
ai_data jsonb NOT NULL DEFAULT '{}',
user_input jsonb NOT NULL DEFAULT '{}',
created_at TIMESTAMP NOT NULL DEFAULT now(),
updated_at TIMESTAMP NOT NULL DEFAULT now()
)
`);
await q.query(
`CREATE INDEX IF NOT EXISTS "IDX_tripz_vehicles_tenant_driver" ON tripz_vehicles (tenant_id, driver_id)`,
);
const add = (col: string, type: string) =>
q.query(`ALTER TABLE tripz_drivers ADD COLUMN IF NOT EXISTS ${col} ${type}`);
await add('gender', 'varchar');
await add('national_number', 'varchar');
await add('national_number_bidx', 'varchar');
await add('name_arabic', 'varchar');
await add('birthdate', 'date');
await add('address', 'varchar');
await add('license_type', 'varchar');
await add('license_categories', 'varchar');
await add('license_issue', 'date');
await add('license_expiry', 'date');
await add('rejected_reason', 'varchar');
await add(`ai_data`, `jsonb NOT NULL DEFAULT '{}'`);
await add(`user_input`, `jsonb NOT NULL DEFAULT '{}'`);
// فهرس فريد على الرقم الوطني لكل مستأجر (عبر الفهرس الأعمى، لا العمود المشفّر
// — العشوائي لا يصلح للتفرّد). جزئي: يتجاهل السائقين بلا رقم وطني بعد.
await q.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_tripz_drivers_tenant_national"
ON tripz_drivers (tenant_id, national_number_bidx)
WHERE national_number_bidx IS NOT NULL
`);
}
public async down(q: QueryRunner): Promise<void> {
await q.query(`DROP INDEX IF EXISTS "UQ_tripz_drivers_tenant_national"`);
for (const c of [
'user_input', 'ai_data', 'rejected_reason', 'license_expiry', 'license_issue',
'license_categories', 'license_type', 'address', 'birthdate', 'name_arabic',
'national_number_bidx', 'national_number', 'gender',
]) {
await q.query(`ALTER TABLE tripz_drivers DROP COLUMN IF EXISTS ${c}`);
}
await q.query(`DROP TABLE IF EXISTS tripz_vehicles`);
}
}
@@ -0,0 +1,34 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* شريحة B الثانية (docs/17 — B4/B5/B8):
* - نقاط التوقف (`stops` jsonb).
* - الحجز المسبق (`scheduled_at` + حالة `scheduled`).
* - مطابقة الوجهة (`is_destination_match` + `destination_match_discount`).
*/
export class TripStopsSchedulingMatch1721920000000 implements MigrationInterface {
public async up(q: QueryRunner): Promise<void> {
await q.query(`ALTER TABLE tripz_trips ADD COLUMN IF NOT EXISTS stops jsonb NOT NULL DEFAULT '[]'`);
await q.query(`ALTER TABLE tripz_trips ADD COLUMN IF NOT EXISTS scheduled_at timestamptz`);
await q.query(
`ALTER TABLE tripz_trips ADD COLUMN IF NOT EXISTS is_destination_match boolean NOT NULL DEFAULT false`,
);
await q.query(
`ALTER TABLE tripz_trips ADD COLUMN IF NOT EXISTS destination_match_discount numeric(12,3)`,
);
// الكنس يبحث عن scheduled المستحقة زمنياً — فهرس جزئي عليها فقط.
await q.query(`
CREATE INDEX IF NOT EXISTS "IDX_tripz_trips_scheduled_due"
ON tripz_trips (scheduled_at)
WHERE status = 'scheduled'
`);
}
public async down(q: QueryRunner): Promise<void> {
await q.query(`DROP INDEX IF EXISTS "IDX_tripz_trips_scheduled_due"`);
await q.query(`ALTER TABLE tripz_trips DROP COLUMN IF EXISTS destination_match_discount`);
await q.query(`ALTER TABLE tripz_trips DROP COLUMN IF EXISTS is_destination_match`);
await q.query(`ALTER TABLE tripz_trips DROP COLUMN IF EXISTS scheduled_at`);
await q.query(`ALTER TABLE tripz_trips DROP COLUMN IF EXISTS stops`);
}
}
@@ -0,0 +1,47 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* دفترا مال المستأجر (docs/24 — P0): الإيراد والأمانات.
*
* **جدولان منفصلان لا جدول واحد بعمود نوع**: استعلامُ سحبٍ أو تقريرٍ ينسى شرط
* النوع كان سيخلط مال المستأجر بأمانات الركّاب. مع جدولين لا يوجد صفٌّ خطأ
* ليُقرأ أصلاً.
*
* الفهرس الفريد على (tenant_id, ref) هو **حارس منع التسوية المزدوجة**: رسالة
* تأكيد من كليك/شام كاش قد تصل مرتين، فيرفض الثانية. القيم الفارغة متعدّدة
* مسموحة في postgres، فالحركات بلا مرجع خارجي لا تتصادم.
*/
export class TenantWalletLedgers1721930000000 implements MigrationInterface {
private readonly tables = ['tripz_tenant_revenue_ledger', 'tripz_tenant_pending_ledger'];
public async up(q: QueryRunner): Promise<void> {
for (const t of this.tables) {
await q.query(`
CREATE TABLE IF NOT EXISTS ${t} (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id uuid NOT NULL,
amount numeric(14,3) NOT NULL,
currency varchar NOT NULL DEFAULT 'JOD',
reason varchar NOT NULL,
ref varchar,
meta jsonb NOT NULL DEFAULT '{}',
created_at TIMESTAMP NOT NULL DEFAULT now()
)
`);
await q.query(`
CREATE INDEX IF NOT EXISTS "IDX_${t}_tenant_created"
ON ${t} (tenant_id, created_at)
`);
await q.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_${t}_tenant_ref"
ON ${t} (tenant_id, ref)
`);
}
}
public async down(q: QueryRunner): Promise<void> {
for (const t of this.tables) {
await q.query(`DROP TABLE IF EXISTS ${t}`);
}
}
}
@@ -0,0 +1,47 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* سجلّ رسائل المزوّدين الخام (docs/24 §5 — P2): تسوية كليك/شام كاش بلا API.
*
* الفهرس الفريد على (tenant_id, fingerprint) هو حارس التسوية المزدوجة على
* مستوى الرسالة: الجهاز قد يعيد الإرسال بعد انقطاع شبكة، فتصل الرسالة ذاتها
* مرتين — وبلا هذا الفهرس تُسوَّى الفاتورة مرتين ويُشحن الرصيد ضعفين.
*/
export class RawSmsSettlement1721940000000 implements MigrationInterface {
public async up(q: QueryRunner): Promise<void> {
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_pay_raw_sms (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id uuid NOT NULL,
provider varchar NOT NULL,
sender varchar,
body text NOT NULL,
fingerprint varchar NOT NULL,
device_id varchar,
status varchar NOT NULL DEFAULT 'received',
parsed jsonb NOT NULL DEFAULT '{}',
payment_id uuid,
note varchar,
sent_at timestamptz,
received_at TIMESTAMP NOT NULL DEFAULT now()
)
`);
await q.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_tripz_pay_raw_sms_fingerprint"
ON tripz_pay_raw_sms (tenant_id, fingerprint)
`);
// طابور المراجعة يُقرأ بالحالة، والتقارير بالزمن.
await q.query(`
CREATE INDEX IF NOT EXISTS "IDX_tripz_pay_raw_sms_status"
ON tripz_pay_raw_sms (tenant_id, status)
`);
await q.query(`
CREATE INDEX IF NOT EXISTS "IDX_tripz_pay_raw_sms_received"
ON tripz_pay_raw_sms (tenant_id, received_at)
`);
}
public async down(q: QueryRunner): Promise<void> {
await q.query(`DROP TABLE IF EXISTS tripz_pay_raw_sms`);
}
}
@@ -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,219 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* الجداول السبعة الجديدة + أعمدة الشرائح على tripz_drivers.
*
* الجداول:
* - tripz_job_executions — سجل المهام الدورية (O5)
* - tripz_surge_states — حالة التسعير الديناميكي (L4)
* - tripz_bot_tasks — مهام بوتات التواصل (O4)
* - tripz_feature_catalog — كتالوج الميزات القابلة للبيع (N6)
* - tripz_ride_type_catalog — كتالوج أنواع الرحلات العالمي (L2)
* - tripz_marketing_campaigns — حملات التسويق
* - tripz_trip_audio — تسجيلات صوتية للرحلات (R2)
*
* أعمدة إضافية على tripz_drivers: tier, tier_score, total_trips, acceptance_rate.
*/
export class AddNewTablesAndDriverColumns1721970000000
implements MigrationInterface
{
public async up(q: QueryRunner): Promise<void> {
// ─── 1. job_executions ────────────────────────────────────────
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_job_executions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
job_name varchar NOT NULL,
status varchar(20) NOT NULL,
started_at timestamptz NOT NULL,
finished_at timestamptz,
duration_ms int,
items_processed int NOT NULL DEFAULT 0,
error_message text,
tenant_id uuid,
created_at timestamptz NOT NULL DEFAULT now()
)
`);
await q.query(`
CREATE INDEX IF NOT EXISTS "IDX_tripz_job_executions_job_started"
ON tripz_job_executions (job_name, started_at)
`);
// ─── 2. surge_states ──────────────────────────────────────────
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_surge_states (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL,
city varchar NOT NULL,
service_class varchar NOT NULL,
multiplier numeric(5,2) NOT NULL DEFAULT 1.0,
max_multiplier numeric(5,2) NOT NULL DEFAULT 2.0,
min_multiplier numeric(5,2) NOT NULL DEFAULT 1.0,
demand_count int NOT NULL DEFAULT 0,
supply_count int NOT NULL DEFAULT 0,
last_sample_at timestamptz,
last_changed_at timestamptz,
manual_override boolean NOT NULL DEFAULT false,
manual_note varchar,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
)
`);
await q.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_tripz_surge_states_tenant_city_class"
ON tripz_surge_states (tenant_id, city, service_class)
`);
// ─── 3. bot_tasks ─────────────────────────────────────────────
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_bot_tasks (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL,
platform varchar NOT NULL,
task_type varchar NOT NULL,
status varchar NOT NULL DEFAULT 'pending',
config jsonb NOT NULL DEFAULT '{}',
result jsonb NOT NULL DEFAULT '{}',
items_processed int NOT NULL DEFAULT 0,
items_failed int NOT NULL DEFAULT 0,
error_message text,
started_at timestamptz,
completed_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
)
`);
// ─── 4. feature_catalog ───────────────────────────────────────
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_feature_catalog (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
key varchar(64) NOT NULL,
name_ar varchar NOT NULL,
name_en varchar NOT NULL,
category varchar NOT NULL DEFAULT 'add_on',
monthly_price numeric(10,3) NOT NULL DEFAULT 0,
setup_fee numeric(10,3) NOT NULL DEFAULT 0,
currency varchar NOT NULL DEFAULT 'JOD',
description_ar text,
sort_order int NOT NULL DEFAULT 0,
active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
)
`);
await q.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_tripz_feature_catalog_key"
ON tripz_feature_catalog (key)
`);
// ─── 5. ride_type_catalog ─────────────────────────────────────
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_ride_type_catalog (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
code varchar NOT NULL,
name_ar varchar NOT NULL,
name_en varchar,
vehicle_kind varchar NOT NULL DEFAULT 'car',
women_only boolean NOT NULL DEFAULT false,
round_trip_supported boolean NOT NULL DEFAULT true,
requires_weight boolean NOT NULL DEFAULT false,
max_seats int NOT NULL DEFAULT 4,
icon varchar,
sort int NOT NULL DEFAULT 0,
active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
)
`);
await q.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_tripz_ride_type_catalog_code"
ON tripz_ride_type_catalog (code)
`);
// ─── 6. marketing_campaigns ───────────────────────────────────
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_marketing_campaigns (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL,
name varchar NOT NULL,
type varchar NOT NULL,
status varchar NOT NULL DEFAULT 'draft',
title text,
body text,
data jsonb NOT NULL DEFAULT '{}',
targeting jsonb NOT NULL DEFAULT '{}',
sent_count int NOT NULL DEFAULT 0,
open_count int NOT NULL DEFAULT 0,
click_count int NOT NULL DEFAULT 0,
scheduled_at timestamptz,
completed_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
)
`);
// ─── 7. trip_audio ────────────────────────────────────────────
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_trip_audio (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL,
trip_id uuid NOT NULL,
uploaded_by varchar NOT NULL,
file_key varchar NOT NULL,
file_ext varchar NOT NULL DEFAULT 'm4a',
duration_sec int,
file_size bigint,
destination_name varchar,
origin_lat double precision,
origin_lng double precision,
dest_lat double precision,
dest_lng double precision,
trip_status_at_upload varchar,
uploaded_at timestamptz NOT NULL DEFAULT now()
)
`);
await q.query(`
CREATE INDEX IF NOT EXISTS "IDX_tripz_trip_audio_tenant_trip"
ON tripz_trip_audio (tenant_id, trip_id)
`);
await q.query(`
CREATE INDEX IF NOT EXISTS "IDX_tripz_trip_audio_trip"
ON tripz_trip_audio (trip_id)
`);
// ─── 8. أعمدة الشرائح على tripz_drivers ──────────────────────
await q.query(`
ALTER TABLE tripz_drivers
ADD COLUMN IF NOT EXISTS tier varchar NOT NULL DEFAULT 'bronze',
ADD COLUMN IF NOT EXISTS tier_score int NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS total_trips int NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS acceptance_rate numeric(5,2) NOT NULL DEFAULT 100
`);
}
public async down(q: QueryRunner): Promise<void> {
// إزالة أعمدة الشرائح
await q.query(`
ALTER TABLE tripz_drivers
DROP COLUMN IF EXISTS acceptance_rate,
DROP COLUMN IF EXISTS total_trips,
DROP COLUMN IF EXISTS tier_score,
DROP COLUMN IF EXISTS tier
`);
// حذف الجداول (الترتيب العكسي للعلاقات)
await q.query(`DROP INDEX IF EXISTS "IDX_tripz_trip_audio_trip"`);
await q.query(`DROP INDEX IF EXISTS "IDX_tripz_trip_audio_tenant_trip"`);
await q.query(`DROP TABLE IF EXISTS tripz_trip_audio`);
await q.query(`DROP TABLE IF EXISTS tripz_marketing_campaigns`);
await q.query(`DROP INDEX IF EXISTS "UQ_tripz_ride_type_catalog_code"`);
await q.query(`DROP TABLE IF EXISTS tripz_ride_type_catalog`);
await q.query(`DROP INDEX IF EXISTS "UQ_tripz_feature_catalog_key"`);
await q.query(`DROP TABLE IF EXISTS tripz_feature_catalog`);
await q.query(`DROP TABLE IF EXISTS tripz_bot_tasks`);
await q.query(`DROP INDEX IF EXISTS "UQ_tripz_surge_states_tenant_city_class"`);
await q.query(`DROP TABLE IF EXISTS tripz_surge_states`);
await q.query(`DROP INDEX IF EXISTS "IDX_tripz_job_executions_job_started"`);
await q.query(`DROP TABLE IF EXISTS tripz_job_executions`);
}
}
@@ -0,0 +1,31 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* فصل هوية الراكب عن السائق (قرار المالك 2026-07-20 — الخيار ب).
*
* قبل: القيد الفريد `(tenant_id, phone_bidx)` يعني مستخدماً واحداً لكل رقم في
* كل مستأجر — فتسجيل الرقم كسائق (`setRole('driver')`) يقلب سجلّه للأبد ويكسر
* وضع الراكب لنفس الرقم. بعد: الرقم الواحد يملك سجلّاً للراكب وآخر للسائق
* (ومحفظتين منفصلتين تلقائياً لأنها مربوطة بـ `user_id`).
*
* التغيير: القيد الفريد يصبح `(tenant_id, phone_bidx, role)`. لا فقدان بيانات —
* السجلات القائمة تبقى كما هي؛ الجديد فقط أنه يُسمح بسجلٍّ ثانٍ بدور مختلف.
*/
export class SeparateRiderDriverIdentity1721980000000 implements MigrationInterface {
public async up(q: QueryRunner): Promise<void> {
await q.query(`DROP INDEX IF EXISTS "UQ_tripz_users_tenant_phone_bidx"`);
await q.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_tripz_users_tenant_phone_bidx_role"
ON tripz_users (tenant_id, phone_bidx, role)
`);
}
public async down(q: QueryRunner): Promise<void> {
await q.query(`DROP INDEX IF EXISTS "UQ_tripz_users_tenant_phone_bidx_role"`);
// العودة قد تفشل لو وُجد رقم بدورين — لا يمكن استعادة قيد أضيق مع بيانات تخالفه.
await q.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_tripz_users_tenant_phone_bidx"
ON tripz_users (tenant_id, phone_bidx)
`);
}
}
@@ -0,0 +1,60 @@
import { writeFileSync } from 'fs';
import { join } from 'path';
import {
buildDefinition,
COUNTRY_TARIFFS,
SEEDED_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 SEEDED_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}`);
@@ -0,0 +1,299 @@
-- 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,"per_km":0.184,"per_min":0.05,"per_min_waiting":0.074},{"name":"normal_day","from":"09:30","to":"16:00","flag":0,"per_km":0.184,"per_min":0.043,"per_min_waiting":0.065},{"name":"evening_peak","from":"16:00","to":"19:30","flag":0,"per_km":0.184,"per_min":0.05,"per_min_waiting":0.074},{"name":"normal_evening","from":"19:30","to":"23:00","flag":0,"per_km":0.184,"per_min":0.043,"per_min_waiting":0.065},{"name":"late_night","from":"23:00","to":"07:00","flag":0,"per_km":0.184,"per_min":0.043,"per_min_waiting":0.065}],"booking_fee":0,"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,"per_km":0.239,"per_min":0.05,"per_min_waiting":0.074},{"name":"normal_day","from":"09:30","to":"16:00","flag":0,"per_km":0.239,"per_min":0.043,"per_min_waiting":0.065},{"name":"evening_peak","from":"16:00","to":"19:30","flag":0,"per_km":0.239,"per_min":0.05,"per_min_waiting":0.074},{"name":"normal_evening","from":"19:30","to":"23:00","flag":0,"per_km":0.239,"per_min":0.043,"per_min_waiting":0.065},{"name":"late_night","from":"23:00","to":"07:00","flag":0,"per_km":0.239,"per_min":0.043,"per_min_waiting":0.065}],"booking_fee":0,"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,"per_km":0.201,"per_min":0.05,"per_min_waiting":0.074},{"name":"normal_day","from":"09:30","to":"16:00","flag":0,"per_km":0.201,"per_min":0.043,"per_min_waiting":0.065},{"name":"evening_peak","from":"16:00","to":"19:30","flag":0,"per_km":0.201,"per_min":0.05,"per_min_waiting":0.074},{"name":"normal_evening","from":"19:30","to":"23:00","flag":0,"per_km":0.201,"per_min":0.043,"per_min_waiting":0.065},{"name":"late_night","from":"23:00","to":"07:00","flag":0,"per_km":0.201,"per_min":0.043,"per_min_waiting":0.065}],"booking_fee":0,"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,"per_km":0.223,"per_min":0.05,"per_min_waiting":0.074},{"name":"normal_day","from":"09:30","to":"16:00","flag":0,"per_km":0.223,"per_min":0.043,"per_min_waiting":0.065},{"name":"evening_peak","from":"16:00","to":"19:30","flag":0,"per_km":0.223,"per_min":0.05,"per_min_waiting":0.074},{"name":"normal_evening","from":"19:30","to":"23:00","flag":0,"per_km":0.223,"per_min":0.043,"per_min_waiting":0.065},{"name":"late_night","from":"23:00","to":"07:00","flag":0,"per_km":0.223,"per_min":0.043,"per_min_waiting":0.065}],"booking_fee":0,"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,"per_km":0.278,"per_min":0.05,"per_min_waiting":0.074},{"name":"normal_day","from":"09:30","to":"16:00","flag":0,"per_km":0.278,"per_min":0.043,"per_min_waiting":0.065},{"name":"evening_peak","from":"16:00","to":"19:30","flag":0,"per_km":0.278,"per_min":0.05,"per_min_waiting":0.074},{"name":"normal_evening","from":"19:30","to":"23:00","flag":0,"per_km":0.278,"per_min":0.043,"per_min_waiting":0.065},{"name":"late_night","from":"23:00","to":"07:00","flag":0,"per_km":0.278,"per_min":0.043,"per_min_waiting":0.065}],"booking_fee":0,"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,"per_km":0.167,"per_min":0.05,"per_min_waiting":0.074},{"name":"normal_day","from":"09:30","to":"16:00","flag":0,"per_km":0.167,"per_min":0.043,"per_min_waiting":0.065},{"name":"evening_peak","from":"16:00","to":"19:30","flag":0,"per_km":0.167,"per_min":0.05,"per_min_waiting":0.074},{"name":"normal_evening","from":"19:30","to":"23:00","flag":0,"per_km":0.167,"per_min":0.043,"per_min_waiting":0.065},{"name":"late_night","from":"23:00","to":"07:00","flag":0,"per_km":0.167,"per_min":0.043,"per_min_waiting":0.065}],"booking_fee":0,"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,"per_km":0.258,"per_min":0.05,"per_min_waiting":0.074},{"name":"normal_day","from":"09:30","to":"16:00","flag":0,"per_km":0.258,"per_min":0.043,"per_min_waiting":0.065},{"name":"evening_peak","from":"16:00","to":"19:30","flag":0,"per_km":0.258,"per_min":0.05,"per_min_waiting":0.074},{"name":"normal_evening","from":"19:30","to":"23:00","flag":0,"per_km":0.258,"per_min":0.043,"per_min_waiting":0.065},{"name":"late_night","from":"23:00","to":"07:00","flag":0,"per_km":0.258,"per_min":0.043,"per_min_waiting":0.065}],"booking_fee":0,"min_fare":1.1,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":14}}'::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,"per_km":0.158,"per_min":0.05,"per_min_waiting":0.074},{"name":"normal_day","from":"09:30","to":"16:00","flag":0,"per_km":0.158,"per_min":0.043,"per_min_waiting":0.065},{"name":"evening_peak","from":"16:00","to":"19:30","flag":0,"per_km":0.158,"per_min":0.05,"per_min_waiting":0.074},{"name":"normal_evening","from":"19:30","to":"23:00","flag":0,"per_km":0.158,"per_min":0.043,"per_min_waiting":0.065},{"name":"late_night","from":"23:00","to":"07:00","flag":0,"per_km":0.158,"per_min":0.043,"per_min_waiting":0.065}],"booking_fee":0,"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":0,"per_km":4,"per_min":1.4,"per_min_waiting":2.1},{"name":"normal_day","from":"09:30","to":"16:00","flag":0,"per_km":4,"per_min":0.8,"per_min_waiting":1.2},{"name":"evening_peak","from":"16:00","to":"19:30","flag":0,"per_km":4,"per_min":1.4,"per_min_waiting":2.1},{"name":"normal_evening","from":"19:30","to":"23:00","flag":0,"per_km":4,"per_min":0.8,"per_min_waiting":1.2},{"name":"late_night","from":"23:00","to":"07:00","flag":0,"per_km":4,"per_min":1.5,"per_min_waiting":2.25}],"booking_fee":0,"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":0,"per_km":5.2,"per_min":1.4,"per_min_waiting":2.1},{"name":"normal_day","from":"09:30","to":"16:00","flag":0,"per_km":5.2,"per_min":0.8,"per_min_waiting":1.2},{"name":"evening_peak","from":"16:00","to":"19:30","flag":0,"per_km":5.2,"per_min":1.4,"per_min_waiting":2.1},{"name":"normal_evening","from":"19:30","to":"23:00","flag":0,"per_km":5.2,"per_min":0.8,"per_min_waiting":1.2},{"name":"late_night","from":"23:00","to":"07:00","flag":0,"per_km":5.2,"per_min":1.5,"per_min_waiting":2.25}],"booking_fee":0,"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":0,"per_km":4.36,"per_min":1.4,"per_min_waiting":2.1},{"name":"normal_day","from":"09:30","to":"16:00","flag":0,"per_km":4.36,"per_min":0.8,"per_min_waiting":1.2},{"name":"evening_peak","from":"16:00","to":"19:30","flag":0,"per_km":4.36,"per_min":1.4,"per_min_waiting":2.1},{"name":"normal_evening","from":"19:30","to":"23:00","flag":0,"per_km":4.36,"per_min":0.8,"per_min_waiting":1.2},{"name":"late_night","from":"23:00","to":"07:00","flag":0,"per_km":4.36,"per_min":1.5,"per_min_waiting":2.25}],"booking_fee":0,"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":0,"per_km":4.84,"per_min":1.4,"per_min_waiting":2.1},{"name":"normal_day","from":"09:30","to":"16:00","flag":0,"per_km":4.84,"per_min":0.8,"per_min_waiting":1.2},{"name":"evening_peak","from":"16:00","to":"19:30","flag":0,"per_km":4.84,"per_min":1.4,"per_min_waiting":2.1},{"name":"normal_evening","from":"19:30","to":"23:00","flag":0,"per_km":4.84,"per_min":0.8,"per_min_waiting":1.2},{"name":"late_night","from":"23:00","to":"07:00","flag":0,"per_km":4.84,"per_min":1.5,"per_min_waiting":2.25}],"booking_fee":0,"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":0,"per_km":6.04,"per_min":1.4,"per_min_waiting":2.1},{"name":"normal_day","from":"09:30","to":"16:00","flag":0,"per_km":6.04,"per_min":0.8,"per_min_waiting":1.2},{"name":"evening_peak","from":"16:00","to":"19:30","flag":0,"per_km":6.04,"per_min":1.4,"per_min_waiting":2.1},{"name":"normal_evening","from":"19:30","to":"23:00","flag":0,"per_km":6.04,"per_min":0.8,"per_min_waiting":1.2},{"name":"late_night","from":"23:00","to":"07:00","flag":0,"per_km":6.04,"per_min":1.5,"per_min_waiting":2.25}],"booking_fee":0,"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":0,"per_km":3.64,"per_min":1.4,"per_min_waiting":2.1},{"name":"normal_day","from":"09:30","to":"16:00","flag":0,"per_km":3.64,"per_min":0.8,"per_min_waiting":1.2},{"name":"evening_peak","from":"16:00","to":"19:30","flag":0,"per_km":3.64,"per_min":1.4,"per_min_waiting":2.1},{"name":"normal_evening","from":"19:30","to":"23:00","flag":0,"per_km":3.64,"per_min":0.8,"per_min_waiting":1.2},{"name":"late_night","from":"23:00","to":"07:00","flag":0,"per_km":3.64,"per_min":1.5,"per_min_waiting":2.25}],"booking_fee":0,"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":0,"per_km":5.6,"per_min":1.4,"per_min_waiting":2.1},{"name":"normal_day","from":"09:30","to":"16:00","flag":0,"per_km":5.6,"per_min":0.8,"per_min_waiting":1.2},{"name":"evening_peak","from":"16:00","to":"19:30","flag":0,"per_km":5.6,"per_min":1.4,"per_min_waiting":2.1},{"name":"normal_evening","from":"19:30","to":"23:00","flag":0,"per_km":5.6,"per_min":0.8,"per_min_waiting":1.2},{"name":"late_night","from":"23:00","to":"07:00","flag":0,"per_km":5.6,"per_min":1.5,"per_min_waiting":2.25}],"booking_fee":0,"min_fare":20,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":12}}'::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":0,"per_km":3.44,"per_min":1.4,"per_min_waiting":2.1},{"name":"normal_day","from":"09:30","to":"16:00","flag":0,"per_km":3.44,"per_min":0.8,"per_min_waiting":1.2},{"name":"evening_peak","from":"16:00","to":"19:30","flag":0,"per_km":3.44,"per_min":1.4,"per_min_waiting":2.1},{"name":"normal_evening","from":"19:30","to":"23:00","flag":0,"per_km":3.44,"per_min":0.8,"per_min_waiting":1.2},{"name":"late_night","from":"23:00","to":"07:00","flag":0,"per_km":3.44,"per_min":1.5,"per_min_waiting":2.25}],"booking_fee":0,"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":0,"per_km":40,"per_min":10,"per_min_waiting":15},{"name":"normal_day","from":"09:30","to":"16:00","flag":0,"per_km":40,"per_min":9,"per_min_waiting":14},{"name":"evening_peak","from":"16:00","to":"19:30","flag":0,"per_km":40,"per_min":10,"per_min_waiting":15},{"name":"normal_evening","from":"19:30","to":"23:00","flag":0,"per_km":40,"per_min":9,"per_min_waiting":14},{"name":"late_night","from":"23:00","to":"07:00","flag":0,"per_km":40,"per_min":11,"per_min_waiting":17}],"booking_fee":0,"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":0,"per_km":52,"per_min":10,"per_min_waiting":15},{"name":"normal_day","from":"09:30","to":"16:00","flag":0,"per_km":52,"per_min":9,"per_min_waiting":14},{"name":"evening_peak","from":"16:00","to":"19:30","flag":0,"per_km":52,"per_min":10,"per_min_waiting":15},{"name":"normal_evening","from":"19:30","to":"23:00","flag":0,"per_km":52,"per_min":9,"per_min_waiting":14},{"name":"late_night","from":"23:00","to":"07:00","flag":0,"per_km":52,"per_min":11,"per_min_waiting":17}],"booking_fee":0,"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":0,"per_km":44,"per_min":10,"per_min_waiting":15},{"name":"normal_day","from":"09:30","to":"16:00","flag":0,"per_km":44,"per_min":9,"per_min_waiting":14},{"name":"evening_peak","from":"16:00","to":"19:30","flag":0,"per_km":44,"per_min":10,"per_min_waiting":15},{"name":"normal_evening","from":"19:30","to":"23:00","flag":0,"per_km":44,"per_min":9,"per_min_waiting":14},{"name":"late_night","from":"23:00","to":"07:00","flag":0,"per_km":44,"per_min":11,"per_min_waiting":17}],"booking_fee":0,"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":0,"per_km":48,"per_min":10,"per_min_waiting":15},{"name":"normal_day","from":"09:30","to":"16:00","flag":0,"per_km":48,"per_min":9,"per_min_waiting":14},{"name":"evening_peak","from":"16:00","to":"19:30","flag":0,"per_km":48,"per_min":10,"per_min_waiting":15},{"name":"normal_evening","from":"19:30","to":"23:00","flag":0,"per_km":48,"per_min":9,"per_min_waiting":14},{"name":"late_night","from":"23:00","to":"07:00","flag":0,"per_km":48,"per_min":11,"per_min_waiting":17}],"booking_fee":0,"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":0,"per_km":60,"per_min":10,"per_min_waiting":15},{"name":"normal_day","from":"09:30","to":"16:00","flag":0,"per_km":60,"per_min":9,"per_min_waiting":14},{"name":"evening_peak","from":"16:00","to":"19:30","flag":0,"per_km":60,"per_min":10,"per_min_waiting":15},{"name":"normal_evening","from":"19:30","to":"23:00","flag":0,"per_km":60,"per_min":9,"per_min_waiting":14},{"name":"late_night","from":"23:00","to":"07:00","flag":0,"per_km":60,"per_min":11,"per_min_waiting":17}],"booking_fee":0,"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":0,"per_km":36,"per_min":10,"per_min_waiting":15},{"name":"normal_day","from":"09:30","to":"16:00","flag":0,"per_km":36,"per_min":9,"per_min_waiting":14},{"name":"evening_peak","from":"16:00","to":"19:30","flag":0,"per_km":36,"per_min":10,"per_min_waiting":15},{"name":"normal_evening","from":"19:30","to":"23:00","flag":0,"per_km":36,"per_min":9,"per_min_waiting":14},{"name":"late_night","from":"23:00","to":"07:00","flag":0,"per_km":36,"per_min":11,"per_min_waiting":17}],"booking_fee":0,"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":0,"per_km":56,"per_min":10,"per_min_waiting":15},{"name":"normal_day","from":"09:30","to":"16:00","flag":0,"per_km":56,"per_min":9,"per_min_waiting":14},{"name":"evening_peak","from":"16:00","to":"19:30","flag":0,"per_km":56,"per_min":10,"per_min_waiting":15},{"name":"normal_evening","from":"19:30","to":"23:00","flag":0,"per_km":56,"per_min":9,"per_min_waiting":14},{"name":"late_night","from":"23:00","to":"07:00","flag":0,"per_km":56,"per_min":11,"per_min_waiting":17}],"booking_fee":0,"min_fare":150,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":11}}'::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":0,"per_km":34,"per_min":10,"per_min_waiting":15},{"name":"normal_day","from":"09:30","to":"16:00","flag":0,"per_km":34,"per_min":9,"per_min_waiting":14},{"name":"evening_peak","from":"16:00","to":"19:30","flag":0,"per_km":34,"per_min":10,"per_min_waiting":15},{"name":"normal_evening","from":"19:30","to":"23:00","flag":0,"per_km":34,"per_min":9,"per_min_waiting":14},{"name":"late_night","from":"23:00","to":"07:00","flag":0,"per_km":34,"per_min":11,"per_min_waiting":17}],"booking_fee":0,"min_fare":150,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":11}}'::jsonb
FROM tripz_tenants t WHERE t.country_pack = 'sy';
COMMIT;
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { GeminiService } from './gemini.service';
@Global()
@Module({
providers: [GeminiService],
exports: [GeminiService],
})
export class GeminiModule {}
@@ -0,0 +1,104 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
export interface GeminiImage {
buffer: Buffer;
mime?: string;
}
/**
* محوّل Gemini (رؤية) — قراءة الوثائق (استخراج الحقول) ومطابقة الوجه.
* يُستخدم في توثيق السائق كذكاء اصطناعي (نمط سيرو). بلا GEMINI_API_KEY:
* يرجّع { enabled:false } دون فشل.
*/
@Injectable()
export class GeminiService {
private readonly logger = new Logger('Gemini');
constructor(private readonly config: ConfigService) { }
get enabled(): boolean {
return !!this.config.get<string>('gemini.apiKey');
}
private endpoint(): string {
const model = this.config.get<string>('gemini.model') ?? 'gemini-flash-lite-latest';
const key = this.config.get<string>('gemini.apiKey');
return `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${key}`;
}
/** يرسل نصاً + صوراً ويعيد JSON مُحلَّلاً من رد Gemini. */
private async generateJson(prompt: string, images: GeminiImage[]): Promise<any> {
const parts: any[] = [{ text: prompt }];
for (const img of images) {
parts.push({
inline_data: { mime_type: img.mime ?? 'image/jpeg', data: img.buffer.toString('base64') },
});
}
const res = await fetch(this.endpoint(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contents: [{ parts }],
generationConfig: { temperature: 0, responseMimeType: 'application/json' },
}),
});
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`Gemini ${res.status}: ${body.slice(0, 300)}`);
}
const data: any = await res.json();
const text = data?.candidates?.[0]?.content?.parts?.[0]?.text ?? '{}';
try {
return JSON.parse(text.replace(/^```json\s*|\s*```$/g, '').trim());
} catch {
return { raw: text };
}
}
/**
* يستخرج حقول تحويل مالي من نصّ رسالة المزوّد (docs/24 §5 — P2).
*
* `temperature: 0` وتعليماتٌ صريحة بإرجاع `null` عند عدم اليقين: النموذج
* الذي «يخمّن» مبلغاً غير مذكور يسوّي فاتورة بمال لم يصل. عدم المطابقة
* ومراجعةٌ بشرية أهون بكثير من تسويةٍ خاطئة.
*/
async extractTransferSms(body: string, provider: string): Promise<any> {
const prompt = [
`أنت محلّل رسائل تحويل مالي من مزوّد الدفع "${provider}".`,
'استخرج من النصّ التالي JSON بالحقول:',
'{ "amount": number|null, "currency": string|null, "reference": string|null,',
' "sender_name": string|null, "sender_phone": string|null, "is_incoming": boolean }',
'',
'قواعد صارمة:',
'- `amount` المبلغ المُستلَم فقط. إن لم يُذكر صراحةً فأعد null — لا تحسبه ولا تخمّنه.',
'- `reference` رقم العملية/الحوالة كما ورد حرفياً.',
'- `is_incoming` = true فقط إن كانت الرسالة تؤكّد **استلام** مبلغ.',
'- أي حقل غير مذكور صراحةً = null. لا تخترع قيمة أبداً.',
'',
'النصّ:',
body,
].join('\n');
return this.generateJson(prompt, []);
}
/** يستخرج حقول وثيقة من صورتها. */
async extractDocument(image: GeminiImage, docType: string): Promise<any> {
if (!this.enabled) return { enabled: false };
const prompt =
`استخرج بيانات وثيقة من نوع "${docType}" من الصورة. ` +
`أعِد JSON فقط بالمفاتيح: number, holder_name, issue_date (YYYY-MM-DD), expiry_date (YYYY-MM-DD). ` +
`استخدم null لأي حقل غير موجود.`;
return this.generateJson(prompt, [image]);
}
/** يقارن وجه السيلفي بصورة الهوية. */
async faceMatch(selfie: GeminiImage, id: GeminiImage): Promise<any> {
if (!this.enabled) return { enabled: false };
const prompt =
`الصورة الأولى سيلفي شخص، والثانية صورة هوية. قارن الوجهين. ` +
`أعِد JSON فقط: {"match": true/false, "score": رقم 0..1, "reason": "سبب مختصر"}.`;
return this.generateJson(prompt, [selfie, id]);
}
}
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { NabehService } from './nabeh.service';
@Global()
@Module({
providers: [NabehService],
exports: [NabehService],
})
export class NabehModule {}
@@ -0,0 +1,74 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
/**
* محوّل Nabeh لإرسال OTP عبر واتساب.
* التدفق: login (يُخزَّن التوكن) ثم otp/send برمزنا نحن.
*/
@Injectable()
export class NabehService {
private readonly logger = new Logger('Nabeh');
private token: string | null = null;
private tokenExp = 0;
constructor(private readonly config: ConfigService) {}
private get base() {
return this.config.get<string>('nabeh.baseUrl');
}
private async ensureToken(): Promise<string> {
const now = Math.floor(Date.now() / 1000);
if (this.token && now < this.tokenExp - 60) return this.token;
const res = await fetch(`${this.base}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: this.config.get<string>('nabeh.email'),
password: this.config.get<string>('nabeh.password'),
}),
});
if (!res.ok) throw new Error(`Nabeh login failed: ${res.status}`);
const data: any = await res.json();
const token = data.token || data.access_token || data.data?.token;
if (!token) throw new Error('Nabeh login: no token in response');
this.token = token;
// exp من JWT إن وُجد، وإلا افتراض ساعة
this.tokenExp = NabehService.jwtExp(token) ?? now + 3600;
return token;
}
/** يرسل الرمز عبر واتساب. phone بصيغة دولية بلا + (مثل 9627xxxxxxx). */
async sendOtp(phone: string, code: string): Promise<void> {
const token = await this.ensureToken();
const res = await fetch(`${this.base}/api/otp/send`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
phone,
type: this.config.get<string>('nabeh.otpType') ?? 'text',
code,
}),
});
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`Nabeh otp/send failed: ${res.status} ${body}`);
}
this.logger.log(`OTP sent via WhatsApp to ${phone}`);
}
private static jwtExp(token: string): number | null {
try {
const payload = JSON.parse(
Buffer.from(token.split('.')[1], 'base64').toString('utf8'),
);
return typeof payload.exp === 'number' ? payload.exp : null;
} catch {
return null;
}
}
}
@@ -0,0 +1,66 @@
/**
* تعليمات ومساعد الذكاء الاصطناعي لمنصة Tripz (تريبز)
* لتأجير وإطلاق تطبيقات النقل الذكي وإدارة الأساطيل.
* تُنسخ وتوضع في لوحة تحكم نبيه (Nabeh Hub) تحت قسم روبوت الذكاء الاصطناعي.
*/
export const TRIPZ_AI_BOT_SYSTEM_PROMPT = `أنت مساعد الذكاء الاصطناعي الرسمي ومسؤول المبيعات وخدمة العملاء لمنصة "Tripz" (تريبز) المتخصصة في إطلاق وتأجير تطبيقات نقل الركاب وإدارة أساطيل التكسي والنقل الذكي (White-label SaaS). مهمتك هي الإجابة عن جميع استفسارات العملاء وأصحاب أساطيل النقل والتاكسي بأسلوب ترحيبي، مهني، ومقنع جداً.
التزم بالحقائق والمعلومات الحاكمة التالية بدقة عند الإجابة:
1. التعريف بالخدمة:
- Tripz تقدم نظاماً متكاملاً يشمل: تطبيق الراكب (Rider App)، تطبيق السائق (Driver App)، ولوحة التحكم والتوجيه (Dispatch & Admin Hub).
- يتم إطلاق التطبيقات كاملة باسمك وشعارك وألوانك الخاصة خلال 30 يوماً فقط من توقيع العقد على متجري آبل (App Store) وجوجل (Google Play).
2. الملكية وحفظ الحقوق:
- التطبيق باسمك وشعارك 100%.
- جميع بيانات أسطولك وسائقيك وعملائك هي ملك خالص لك، مع توفير عقد تصدير كود وبيانات مكتوب يضمن حقك كلياً.
3. الشفافية والتسعير وحاسبة التوفير:
- أسعارنا معلنة بالكامل دون أي أسرار أو رسوم خفية أو مفاجآت في الفاتورة.
- يمكنك حساب تكلفة الإطلاق وتوفيرك السنوي المباشر عبر حاسبة التوفير الرقمية: https://tripz-egypt.com/apps/
4. الخبرة والموثوقية:
- محرك تقني مجرب وثابت ومستقر كلياً منذ عام 2019.
- يخدم بنجاح أكثر من 6,000 سائق نشط وآلاف الرحلات اليومية.
5. الأمان وتقنيات الذكاء الاصطناعي:
- نظام أمان متطور للتحقق التلقائي وتدقيق رخص القيادة والوثائق بالذكاء الاصطناعي.
- بصمة وجه ومطابقة رقمية للهوية لمنع الحسابات الوهمية.
- تتبع جغرافي حي (GPS Live Tracking) لمسارات الرحلات وتنبيهات الطوارئ.
6. طرق الدفع والعمولات:
- تدعم المنصة طرق دفع متعددة: الدفع نقداً، شام كاش، سيريتل كاش، إم تي إن كاش (MTN Cash)، والبطاقات البنكية.
- العمولة والتعرفة تُحدد بالكامل حسب رغبة صاحب الأسطول مع خيارات مرنة جداً.
7. للتواصل المباشر وحساب التكلفة:
- رابط حاسبة التوفير والتكلفة: https://tripz-egypt.com/apps/
- واتساب المبيعات المباشر: https://wa.me/201023248456 (رقم التواصل: 201023248456 / @tripzeg).
- ساعات الدعم المباشر: يومياً من 11 صباحاً حتى 5 مساءً، والذكاء الاصطناعي متوفر للرد 24/7.
8. أسلوب التحدث:
- تحدث بأسلوب ترحيبي ودود، محترم، ومهني جداً.
- استخدم عبارات ترحيبية مثل: "يا هلا بك في Tripz"، "أهلين فيك"، "تكرم عينك"، "كيف بقدر أساعدك اليوم بإطلاق تطبيقك الخاص؟".
- كُن مختصراً ومباشراً ومفيداً وركّز على إبراز مزايا التملك والسرعة والشفافية.`;
export async function generateTripzBotResponse(userMessage: string, apiKey: string): Promise<string> {
const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-flash-lite-latest:generateContent?key=${apiKey}`;
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contents: [
{
role: 'user',
parts: [
{ text: TRIPZ_AI_BOT_SYSTEM_PROMPT },
{ text: `سؤال العميل: ${userMessage}` }
]
}
]
})
});
const data: any = await res.json();
const reply = data?.candidates?.[0]?.content?.parts?.[0]?.text;
return reply ? reply.trim() : 'أهلين فيك في Tripz! تكرم عينك، بنطلق لك تطبيقك الخاص باسمك وشعارك خلال 30 يوم بس. بتقدر تحسب توفيرك عبر الحاسبة: https://tripz-egypt.com/apps/ أو تتواصل معنا على واتساب: 201023248456';
}
@@ -0,0 +1,73 @@
import { OtpDispatcher } from './otp-dispatcher.service';
import { OtpProvider } from './otp-provider.interface';
function provider(name: string, result: boolean | (() => boolean)): OtpProvider {
return {
name,
supports: () => true,
send: jest.fn(async () => (typeof result === 'function' ? result() : result)),
};
}
describe('OtpDispatcher — توجيه حسب الدولة + failover (docs/17 D5)', () => {
it('مصر تجرّب Kazumi أولاً', async () => {
const kazumi = provider('kazumi', true);
const nabeh = provider('nabeh', true);
const d = new OtpDispatcher([kazumi, nabeh]);
expect(await d.send('201012345678', '1234', { countryPack: 'eg' })).toBe(true);
expect(kazumi.send).toHaveBeenCalledTimes(1);
expect(nabeh.send).not.toHaveBeenCalled(); // نجح الأول فلا failover
});
it('مصر: فشل Kazumi → يتحوّل إلى Nabeh', async () => {
const kazumi = provider('kazumi', false);
const nabeh = provider('nabeh', true);
const d = new OtpDispatcher([kazumi, nabeh]);
expect(await d.send('201012345678', '1234', { countryPack: 'eg' })).toBe(true);
expect(kazumi.send).toHaveBeenCalledTimes(1);
expect(nabeh.send).toHaveBeenCalledTimes(1); // جرّب البديل
});
it('الأردن يستعمل Nabeh فقط — لا يلمس Kazumi', async () => {
const kazumi = provider('kazumi', true);
const nabeh = provider('nabeh', true);
const d = new OtpDispatcher([kazumi, nabeh]);
expect(await d.send('962790000000', '1234', { countryPack: 'jo' })).toBe(true);
expect(nabeh.send).toHaveBeenCalledTimes(1);
expect(kazumi.send).not.toHaveBeenCalled();
});
it('سوريا تستعمل Nabeh', async () => {
const nabeh = provider('nabeh', true);
const d = new OtpDispatcher([nabeh]);
expect(await d.send('963944000000', '1234', { countryPack: 'sy' })).toBe(true);
});
it('فشل السلسلة كاملة يرجع false — المُستدعي يُفشل الطلب', async () => {
const kazumi = provider('kazumi', false);
const nabeh = provider('nabeh', false);
const d = new OtpDispatcher([kazumi, nabeh]);
expect(await d.send('201012345678', '1234', { countryPack: 'eg' })).toBe(false);
expect(kazumi.send).toHaveBeenCalledTimes(1);
expect(nabeh.send).toHaveBeenCalledTimes(1);
});
it('دولة غير معروفة تسقط لـNabeh (السلسلة الافتراضية)', async () => {
const nabeh = provider('nabeh', true);
const d = new OtpDispatcher([nabeh]);
expect(await d.send('491234567', '1234', { countryPack: 'de' })).toBe(true);
expect(nabeh.send).toHaveBeenCalledTimes(1);
});
it('مزوّد مفقود من التسجيل لا يُسقط السلسلة — يتخطّاه', async () => {
// Kazumi غير مسجَّل، فمصر تسقط لـNabeh وحده.
const nabeh = provider('nabeh', true);
const d = new OtpDispatcher([nabeh]);
expect(await d.send('201012345678', '1234', { countryPack: 'eg' })).toBe(true);
expect(nabeh.send).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,55 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { OTP_PROVIDERS, OtpProvider, OtpSendContext } from './otp-provider.interface';
/**
* سلسلة الـfailover لكل دولة (docs/17 — D5؛ نمط سيرو auth/otp/request.php).
* الأول أساسي، والبقية بدائل تُجرَّب بالترتيب. مصر: Kazumi SMS ثم Nabeh واتساب
* (نفس منطق سيرو). الأردن/سوريا: Nabeh فقط.
*/
const ROUTING: Record<string, string[]> = {
eg: ['kazumi', 'nabeh'],
jo: ['nabeh'],
sy: ['nabeh'],
};
const DEFAULT_CHAIN = ['nabeh'];
/**
* يوجّه إرسال OTP للمزوّد المناسب حسب دولة المستأجر، مع failover.
*
* السبب في وجوده: خدمة واحدة تُستدعى من كل مكان يحتاج إرسال رمز (تسجيل دخول،
* تحقّق سحب لاحقاً) بلا أن يعرف المُستدعي أي مزوّد لأي دولة — تُضاف دولة أو
* مزوّد جديد هنا وحده.
*/
@Injectable()
export class OtpDispatcher {
private readonly logger = new Logger('OtpDispatcher');
private readonly byName = new Map<string, OtpProvider>();
constructor(@Inject(OTP_PROVIDERS) providers: OtpProvider[]) {
for (const p of providers) this.byName.set(p.name, p);
}
/**
* يرسل الرمز عبر أول مزوّد ينجح في سلسلة الدولة.
* يرجع `true` إن نجح أي مزوّد؛ `false` إن فشلت السلسلة كاملة (يقرّر المُستدعي
* حينها إفشال الطلب — طلبٌ بلا رمز يصل = مستخدم عالق).
*/
async send(phone: string, code: string, ctx: OtpSendContext): Promise<boolean> {
const chain = ROUTING[ctx.countryPack] ?? DEFAULT_CHAIN;
for (const name of chain) {
const provider = this.byName.get(name);
if (!provider) {
this.logger.warn(`provider "${name}" not registered — skipping`);
continue;
}
const ok = await provider.send(phone, code, ctx);
if (ok) {
this.logger.log(`sent via ${name} (country=${ctx.countryPack})`);
return true;
}
this.logger.warn(`${name} failed for ${ctx.countryPack} — trying next in chain`);
}
this.logger.error(`all providers failed for country=${ctx.countryPack}`);
return false;
}
}
@@ -0,0 +1,31 @@
/**
* مزوّد إرسال OTP واحد (docs/17 — D5؛ نمط سيرو auth/otp/providers.php).
*
* كل مزوّد يرسل رمزاً **نولّده نحن** (لا يولّده المزوّد) — فالرمز يبقى مصدره
* السيرفر ومخزَّناً في Redis، والمزوّد قناة توصيل فقط. هذا يختلف عن مزوّد
* Intaleq عند سيرو الذي يولّد الرمز بنفسه ويرجعه — استبعدناه عمداً حفاظاً على
* مصدر واحد للرمز.
*/
export interface OtpSendContext {
/** دولة المستأجر (jo | sy | eg) — تحدّد المزوّد. */
countryPack: string;
/** passenger | driver | admin | service — لتخصيص نصّ الرسالة. */
userType?: string;
}
export interface OtpProvider {
/** اسم قصير للتشخيص واللوغ. */
readonly name: string;
/** الدول التي يخدمها هذا المزوّد (رموز countryPack). */
supports(countryPack: string): boolean;
/**
* يرسل الرمز. يرجع `true` عند النجاح المؤكَّد فقط — أي فشل أو استجابة
* غامضة = `false` كي يجرّب المُوزِّع المزوّد التالي في السلسلة.
* **لا يرمي**: الرمي يقطع سلسلة الـfailover.
*/
send(phone: string, code: string, ctx: OtpSendContext): Promise<boolean>;
}
export const OTP_PROVIDERS = Symbol('OTP_PROVIDERS');
@@ -0,0 +1,28 @@
import { Global, Module } from '@nestjs/common';
import { NabehModule } from '../nabeh/nabeh.module';
import { OtpDispatcher } from './otp-dispatcher.service';
import { OTP_PROVIDERS } from './otp-provider.interface';
import { NabehOtpProvider } from './providers/nabeh.provider';
import { KazumiSmsProvider } from './providers/kazumi.provider';
/**
* توجيه OTP متعدد المزوّدين (docs/17 — D5). عالمي: `OtpDispatcher` يُستدعى من
* كل مسار يرسل رمزاً. لإضافة مزوّد: أضِف موفّره هنا وسجّله في مصفوفة
* `OTP_PROVIDERS` وأضِف اسمه لسلسلة دولته في `otp-dispatcher.service.ts`.
*/
@Global()
@Module({
imports: [NabehModule],
providers: [
NabehOtpProvider,
KazumiSmsProvider,
{
provide: OTP_PROVIDERS,
useFactory: (nabeh: NabehOtpProvider, kazumi: KazumiSmsProvider) => [nabeh, kazumi],
inject: [NabehOtpProvider, KazumiSmsProvider],
},
OtpDispatcher,
],
exports: [OtpDispatcher],
})
export class OtpModule {}
@@ -0,0 +1,58 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { OtpProvider, OtpSendContext } from '../otp-provider.interface';
/**
* مزوّد Kazumi (SMS) — مصر (docs/17 — D5؛ من سيرو auth/otp/providers.php).
*
* لماذا SMS لا واتساب في مصر: انتشار واتساب أقل هناك، وSMS أضمن وصولاً.
* لو فشل، يتولّى المُوزِّع الرجوع لمزوّد آخر (Nabeh واتساب) في السلسلة.
*/
@Injectable()
export class KazumiSmsProvider implements OtpProvider {
readonly name = 'kazumi';
private readonly logger = new Logger('KazumiSms');
constructor(private readonly config: ConfigService) {}
supports(countryPack: string): boolean {
return countryPack === 'eg';
}
async send(phone: string, code: string, _ctx: OtpSendContext): Promise<boolean> {
const username = this.config.get<string>('otp.kazumi.username');
const password = this.config.get<string>('otp.kazumi.password');
const sender = this.config.get<string>('otp.kazumi.sender');
if (!username || !password || !sender) {
this.logger.warn('missing Kazumi credentials — skipping (failover will handle)');
return false;
}
try {
const res = await fetch('https://sms.kazumi.me/api/sms/send-sms', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username,
password,
language: 'e',
sender,
receiver: phone,
message: this.config.get<string>('otp.smsTemplate')?.replace('{code}', code)
?? `Your Tripz code is ${code}`,
}),
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) {
this.logger.warn(`HTTP ${res.status}`);
return false;
}
const json: any = await res.json().catch(() => null);
// Kazumi يرجع { message: 'Success' } عند النجاح.
return json?.message === 'Success';
} catch (e: any) {
this.logger.warn(`send failed: ${e?.message}`);
return false;
}
}
}
@@ -0,0 +1,30 @@
import { Injectable, Logger } from '@nestjs/common';
import { NabehService } from '../../nabeh/nabeh.service';
import { OtpProvider, OtpSendContext } from '../otp-provider.interface';
/**
* مزوّد Nabeh (واتساب) — سوريا والأردن (docs/17 — D5).
* غلاف رقيق حول `NabehService` القائم يحوّل الرمي إلى `false` ليعمل ضمن سلسلة
* الـfailover.
*/
@Injectable()
export class NabehOtpProvider implements OtpProvider {
readonly name = 'nabeh';
private readonly logger = new Logger('NabehOtp');
constructor(private readonly nabeh: NabehService) {}
supports(countryPack: string): boolean {
return countryPack === 'jo' || countryPack === 'sy';
}
async send(phone: string, code: string, _ctx: OtpSendContext): Promise<boolean> {
try {
await this.nabeh.sendOtp(phone, code);
return true;
} catch (e: any) {
this.logger.warn(`send failed: ${e?.message}`);
return false;
}
}
}
@@ -0,0 +1,20 @@
import { Injectable } from '@nestjs/common';
import { PaymentAdapter, ChargeResult, WebhookResult } from './payment-adapter.interface';
/** كاش — لا مزوّد خارجي، ينجح فوراً عند الاستلام اليدوي. لا webhook له إطلاقاً. */
@Injectable()
export class CashAdapter implements PaymentAdapter {
readonly name = 'cash';
async charge(): Promise<ChargeResult> {
return { mode: 'instant' };
}
verifyWebhook(): boolean {
return false; // كاش ليس له مزوّد يبعث webhook — أي طلب هنا مزوَّر بالتعريف
}
parseWebhook(): WebhookResult | null {
return null;
}
}
@@ -0,0 +1,116 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Tenant } from '../../database/entities/tenant.entity';
import {
PaymentAdapter,
ChargeContext,
ChargeResult,
WebhookResult,
} from './payment-adapter.interface';
/**
* بوابة CliQ — الدفع الإلكتروني الأردني (BOK + بنوك الأردن).
*
* تدفق العمل:
* 1. `POST /api/v1/payment` — إنشاء عملية دفع.
* 2. يُرجع `paymentUrl` — يُفتح في المتصفح/iframe.
* 3. webhook موقَّع يؤكد الدفع.
*
* الاعتمادات تُقرأ من `tenant.settings.payments.cliq` أولاً، ثم env vars.
*/
@Injectable()
export class CliqAdapter implements PaymentAdapter {
readonly name = 'cliq';
private readonly logger = new Logger('CliQ');
constructor(private readonly config: ConfigService) {}
private get baseUrl(): string {
return this.config.get<string>('cliq.baseUrl') ?? 'https://api.cliq.jo';
}
private creds(tenant: Tenant) {
const t = tenant?.settings?.payments?.cliq ?? {};
return {
merchantId: t.merchantId ?? this.config.get<string>('cliq.merchantId') ?? '',
apiKey: t.apiKey ?? this.config.get<string>('cliq.apiKey') ?? '',
secretKey: t.secretKey ?? this.config.get<string>('cliq.secretKey') ?? '',
terminalId: t.terminalId ?? this.config.get<string>('cliq.terminalId') ?? '',
};
}
async charge(ctx: ChargeContext, tenant: Tenant): Promise<ChargeResult> {
const creds = this.creds(tenant);
if (!creds.merchantId || !creds.apiKey) {
this.logger.warn('CliQ credentials not configured — falling back to invoice');
return {
mode: 'invoice',
reference: `CLIQ-${ctx.paymentId.slice(0, 8)}-${Date.now().toString(36)}`,
instructions: 'CliQ credentials not configured',
};
}
try {
const res = await fetch(`${this.baseUrl}/api/v1/payment`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${creds.apiKey}`,
},
body: JSON.stringify({
merchant_id: creds.merchantId,
terminal_id: creds.terminalId,
amount: ctx.amount,
currency: ctx.currency,
order_id: ctx.paymentId,
customer_phone: ctx.userPhone ?? '',
callback_url: `${this.config.get<string>('app.baseUrl') ?? 'https://api.tripz.app'}/webhooks/payments/cliq`,
}),
});
if (!res.ok) {
const body = await res.text();
this.logger.error(`CliQ charge failed: ${res.status} ${body}`);
throw new Error(`CliQ API ${res.status}`);
}
const data = await res.json();
return {
mode: 'redirect',
redirectUrl: data.paymentUrl ?? data.redirect_url ?? data.url,
providerRef: data.payment_id ?? data.id,
};
} catch (e: any) {
this.logger.error(`CliQ charge error: ${e?.message}`);
throw e;
}
}
verifyWebhook(headers: Record<string, any>, rawBody: any, tenant: Tenant): boolean {
const creds = this.creds(tenant);
const signature = headers['x-cliq-signature'] ?? headers['x-signature'] ?? '';
if (!signature || !creds.secretKey) return false;
// HMAC-SHA256 على الجسم الخام — مطابق لنمط PayMob.
const { createHmac, timingSafeEqual } = require('crypto');
const expected = createHmac('sha256', creds.secretKey)
.update(typeof rawBody === 'string' ? rawBody : JSON.stringify(rawBody))
.digest('hex');
try {
return timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
} catch {
return false;
}
}
parseWebhook(payload: any): WebhookResult | null {
if (!payload) return null;
const status = payload.status ?? payload.payment_status;
if (!status) return null;
return {
providerRef: payload.payment_id ?? payload.id ?? payload.transaction_id ?? '',
success: status === 'success' || status === 'completed' || status === 'paid',
amount: payload.amount ? Number(payload.amount) : undefined,
};
}
}
@@ -0,0 +1,76 @@
import { InvoiceAdapter } from './invoice.adapter';
import { PaymentGatewayRegistry } from './payment-gateway.registry';
import { CashAdapter } from './cash.adapter';
import { PaymobAdapter } from './paymob.adapter';
import { SyriatelAdapter } from './syriatel.adapter';
import { MtnAdapter } from './mtn.adapter';
describe('InvoiceAdapter — كليك/شام كاش/MTN بلا API (docs/24 §5)', () => {
it('يولّد مرجعاً ويعرض حساب الاستلام المضبوط للمستأجر', async () => {
const adapter = new InvoiceAdapter('cliq');
const tenant = { settings: { payments: { transfer_targets: { cliq: '+962790000000' } } } } as any;
const result = await adapter.charge({ paymentId: 'abcdef12', amount: 25, currency: 'JOD' }, tenant);
expect(result.mode).toBe('invoice');
if (result.mode === 'invoice') {
expect(result.reference.startsWith('CLIQ-')).toBe(true);
expect(result.transferTarget).toBe('+962790000000');
expect(result.instructions).toContain('+962790000000');
}
});
it('بلا حساب استلام مضبوط يوضّح ذلك بدل رابط فارغ', async () => {
const adapter = new InvoiceAdapter('mtn');
const result = await adapter.charge({ paymentId: 'x', amount: 10, currency: 'SYP' }, { settings: {} } as any);
if (result.mode === 'invoice') {
expect(result.transferTarget).toBeUndefined();
expect(result.instructions).toContain('لم يُضبط');
}
});
it('لا webhook حقيقياً — التسوية عبر SMS فقط', () => {
const adapter = new InvoiceAdapter('shamcash');
expect(adapter.verifyWebhook({}, {}, {} as any)).toBe(false);
expect(adapter.parseWebhook({})).toBeNull();
});
it('كل نداء يولّد مرجعاً مختلفاً — لا تصادم بين فاتورتين', async () => {
const adapter = new InvoiceAdapter('cliq');
const tenant = { settings: {} } as any;
const a = await adapter.charge({ paymentId: 'p1', amount: 5, currency: 'JOD' }, tenant);
const b = await adapter.charge({ paymentId: 'p2', amount: 5, currency: 'JOD' }, tenant);
if (a.mode === 'invoice' && b.mode === 'invoice') {
expect(a.reference).not.toBe(b.reference);
}
});
});
describe('PaymentGatewayRegistry — يربط الكتالوج بمحوّله', () => {
const config = { get: () => undefined } as any;
const registry = () => new PaymentGatewayRegistry(
new CashAdapter(),
new PaymobAdapter(config),
new SyriatelAdapter(config),
new MtnAdapter(config),
);
it('يعرف كل مزوّدي الكتالوج', () => {
const r = registry();
for (const p of ['cash', 'paymob', 'cliq', 'shamcash', 'mtn', 'syriatel', 'zaincash']) {
expect(r.has(p)).toBe(true);
}
});
it('يرفض مزوّداً مخترعاً', () => {
const r = registry();
expect(r.has('made_up_gateway')).toBe(false);
expect(() => r.get('made_up_gateway')).toThrow();
});
it('cliq وmtn محوّلان مستقلّان لا نفس الكائن — مراجعهما لا تختلط', () => {
const r = registry();
expect(r.get('cliq')).not.toBe(r.get('mtn'));
expect(r.get('cliq').name).toBe('cliq');
expect(r.get('mtn').name).toBe('mtn');
});
});
@@ -0,0 +1,52 @@
import { Injectable } from '@nestjs/common';
import { Tenant } from '../../database/entities/tenant.entity';
import {
PaymentAdapter,
ChargeContext,
ChargeResult,
WebhookResult,
} from './payment-adapter.interface';
/**
* كليك · شام كاش · MTN · سيرياتيل · زين كاش — **بلا API فعلية اليوم**
* (مطابق لما أكّده المالك ولما هو مطبَّق فعلاً في `payment_server` لدى سيرو:
* `create_cliq_invoice.php` / `create_mtn_invoice.php` تُنشئ فاتورة فقط،
* لا نداء حيّاً لبوابة تحويل).
*
* المحوّل الواحد يخدم كل هذه المزوّدات لأن سلوكها متطابق حرفياً: يولّد مرجع
* فاتورة ويعرض للمستخدم أين يحوّل (رقم/حساب المستأجر المسجَّل عند ذاك
* المزوّد)، والتسوية تصل **لاحقاً عبر رسالة SMS** (docs/24 §5 — P2) لا عبر
* webhook من المزوّد — فلا `verifyWebhook` هنا يعيد `true` أبداً.
*
* ⚠️ MTN وسيرياتيل يملكان API حقيقية موثَّقة في سيرو (توكن + OTP داخل
* التطبيق) لكنها تتطلّب بيانات اعتماد إنتاجية حيّة (terminal ID · مفتاح
* خاص · حساب تاجر) لا نملكها هنا لاختبارها فعلياً. البنية جاهزة لاستقبالها
* (`docs/24` بند مفتوح) — لا نبني تكاملاً لا يمكن التحقّق منه.
*/
@Injectable()
export class InvoiceAdapter implements PaymentAdapter {
constructor(readonly name: string) {}
async charge(ctx: ChargeContext, tenant: Tenant): Promise<ChargeResult> {
const targets = tenant?.settings?.payments?.transfer_targets ?? {};
const reference = `${this.name.toUpperCase()}-${ctx.paymentId.slice(0, 8)}-${Date.now().toString(36)}`;
return {
mode: 'invoice',
reference,
transferTarget: targets[this.name] ?? undefined,
instructions: targets[this.name]
? `حوّل ${ctx.amount} ${ctx.currency} إلى ${targets[this.name]} عبر ${this.name}، واذكر المرجع ${reference}`
: `حوّل ${ctx.amount} ${ctx.currency} عبر ${this.name} — لم يُضبط حساب استلام لهذا المستأجر بعد`,
};
}
/** لا webhook حقيقياً من هذه البوابات — التسوية عبر SMS فقط (P2). */
verifyWebhook(_headers?: Record<string, any>, _rawBody?: any, _tenant?: Tenant): boolean {
return false;
}
parseWebhook(_payload?: any): WebhookResult | null {
return null;
}
}
@@ -0,0 +1,133 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Tenant } from '../../database/entities/tenant.entity';
import {
PaymentAdapter,
ChargeContext,
ChargeResult,
WebhookResult,
} from './payment-adapter.interface';
/**
* بوابة MTN للدفع الإلكتروني — MTN Mobile Money / MTN Payment Gateway.
*
* تدفق العمل:
* 1. `POST /api/v1/requestToPay` — طلب دفع من المستخدم.
* 2. يُرجع `referenceId` — التطبيق يتابع الحالة عبر polling.
* 3. webhook / polling يؤكد النجاح.
*
* الاعتمادات تُقرأ من `tenant.settings.payments.mtn` أولاً، ثم env vars.
*/
@Injectable()
export class MtnAdapter implements PaymentAdapter {
readonly name = 'mtn';
private readonly logger = new Logger('MTN');
constructor(private readonly config: ConfigService) {}
private get baseUrl(): string {
return this.config.get<string>('mtn.baseUrl') ?? 'https://proxy.momoapi.mtn.com';
}
private creds(tenant: Tenant) {
const t = tenant?.settings?.payments?.mtn ?? {};
return {
apiKey: t.apiKey ?? this.config.get<string>('mtn.apiKey') ?? '',
apiUser: t.apiUser ?? this.config.get<string>('mtn.apiUser') ?? '',
subscriptionKey: t.subscriptionKey ?? this.config.get<string>('mtn.subscriptionKey') ?? '',
environment: t.environment ?? this.config.get<string>('mtn.environment') ?? 'sandbox',
};
}
private async getAccessToken(creds: { apiKey: string; apiUser: string; subscriptionKey: string }): Promise<string> {
const res = await fetch(`${this.baseUrl}/collection/token/`, {
method: 'POST',
headers: {
Authorization: `Basic ${Buffer.from(`${creds.apiUser}:${creds.apiKey}`).toString('base64')}`,
'Ocp-Apim-Subscription-Key': creds.subscriptionKey,
},
});
if (!res.ok) throw new Error(`MTN token failed: ${res.status}`);
const data = await res.json();
return data.access_token;
}
async charge(ctx: ChargeContext, tenant: Tenant): Promise<ChargeResult> {
const creds = this.creds(tenant);
if (!creds.apiKey || !creds.apiUser) {
this.logger.warn('MTN credentials not configured — falling back to invoice');
return {
mode: 'invoice',
reference: `MTN-${ctx.paymentId.slice(0, 8)}-${Date.now().toString(36)}`,
instructions: 'MTN credentials not configured',
};
}
try {
const token = await this.getAccessToken(creds);
const referenceId = ctx.paymentId;
const res = await fetch(`${this.baseUrl}/collection/v1_0/requestToPay`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
'X-Reference-Id': referenceId,
'X-Target-Environment': creds.environment,
'Ocp-Apim-Subscription-Key': creds.subscriptionKey,
},
body: JSON.stringify({
amount: String(ctx.amount),
currency: ctx.currency,
externalId: ctx.paymentId,
payer: { partyIdType: 'MSISDN', partyId: ctx.userPhone ?? '' },
payerMessage: 'Tripz payment',
payeeNote: `Payment for trip ${ctx.paymentId}`,
}),
});
if (!res.ok && res.status !== 202) {
const body = await res.text();
this.logger.error(`MTN charge failed: ${res.status} ${body}`);
throw new Error(`MTN API ${res.status}`);
}
// MTN يرجع 202 Accepted — التأكيد عبر polling أو webhook.
return {
mode: 'redirect',
redirectUrl: `${this.baseUrl}/collection/v1_0/requestToPay/${referenceId}`,
providerRef: referenceId,
};
} catch (e: any) {
this.logger.error(`MTN charge error: ${e?.message}`);
throw e;
}
}
verifyWebhook(headers: Record<string, any>, rawBody: any, tenant: Tenant): boolean {
const creds = this.creds(tenant);
const signature = headers['x-mtn-signature'] ?? headers['x-signature'] ?? '';
if (!signature || !creds.subscriptionKey) return false;
const { createHmac, timingSafeEqual } = require('crypto');
const expected = createHmac('sha256', creds.subscriptionKey)
.update(typeof rawBody === 'string' ? rawBody : JSON.stringify(rawBody))
.digest('hex');
try {
return timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
} catch {
return false;
}
}
parseWebhook(payload: any): WebhookResult | null {
if (!payload) return null;
const status = payload.status ?? payload.financialTransaction?.status;
if (!status) return null;
return {
providerRef: payload.referenceId ?? payload.externalId ?? '',
success: status === 'SUCCESSFUL' || status === 'completed',
amount: payload.amount ? Number(payload.amount) : undefined,
};
}
}
@@ -0,0 +1,46 @@
import { Tenant } from '../../database/entities/tenant.entity';
/** ما يحتاجه المحوّل لبدء عملية دفع. المبلغ بالوحدة الكبرى (دينار/جنيه لا قروش). */
export interface ChargeContext {
paymentId: string;
amount: number;
currency: string;
userPhone?: string;
}
/**
* نتيجة بدء الدفع — ثلاثة أنماط لا نمط واحد، لأن بوابات المنطقة تختلف جذرياً:
*
* - `instant`: ينجح فوراً (كاش) — لا انتظار.
* - `redirect`: بوابة حقيقية بواجهة دفع (PayMob) — رابط iframe يفتحه التطبيق،
* وتأكيد لاحق عبر webhook **موقَّع** من المزوّد.
* - `invoice`: **لا API فعلية** (كليك/شام كاش/MTN كما هي اليوم) — نولّد مرجعاً
* ونعرض للمستخدم أين يحوّل، والتأكيد عبر **رسالة SMS** (docs/24 §5 — P2)
* لا عبر webhook من المزوّد نفسه.
*/
export type ChargeResult =
| { mode: 'instant' }
| { mode: 'redirect'; redirectUrl: string; providerRef?: string }
| { mode: 'invoice'; reference: string; transferTarget?: string; instructions?: string };
/** نتيجة تحليل حمولة webhook بعد التحقق من توقيعها. */
export interface WebhookResult {
providerRef: string;
success: boolean;
amount?: number;
}
export interface PaymentAdapter {
readonly name: string;
charge(ctx: ChargeContext, tenant: Tenant): Promise<ChargeResult>;
/**
* يتحقّق من توقيع الطلب الوارد. **يجب أن يُنادى قبل أي قراءة للحمولة** —
* القيمة غير الموثَّقة لا تُستهلَك أبداً حتى لو بدت معقولة.
*/
verifyWebhook(headers: Record<string, any>, rawBody: any, tenant: Tenant): boolean;
/** يُنادى فقط بعد `verifyWebhook() === true`. */
parseWebhook(payload: any): WebhookResult | null;
}
@@ -0,0 +1,43 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PaymentAdapter } from './payment-adapter.interface';
import { CashAdapter } from './cash.adapter';
import { PaymobAdapter } from './paymob.adapter';
import { InvoiceAdapter } from './invoice.adapter';
import { SyriatelAdapter } from './syriatel.adapter';
import { MtnAdapter } from './mtn.adapter';
/**
* يربط اسم المزوّد (من كتالوج `payment-methods.ts`) بمحوّله (docs/07).
* إضافة مزوّد جديد بواجهة API فعلية = محوّل جديد + سطر هنا، بلا مساس بمنطق
* الدفع في `PaymentsService`.
*/
@Injectable()
export class PaymentGatewayRegistry {
private readonly adapters = new Map<string, PaymentAdapter>();
constructor(
cash: CashAdapter,
paymob: PaymobAdapter,
syriatel: SyriatelAdapter,
mtn: MtnAdapter,
) {
this.adapters.set(cash.name, cash);
this.adapters.set(paymob.name, paymob);
this.adapters.set(syriatel.name, syriatel);
this.adapters.set(mtn.name, mtn);
// CliQ وشام كاش وزين كاش — فواتير (بلا API فعلية)
for (const provider of ['cliq', 'shamcash', 'zaincash']) {
this.adapters.set(provider, new InvoiceAdapter(provider));
}
}
get(provider: string): PaymentAdapter {
const adapter = this.adapters.get(provider);
if (!adapter) throw new NotFoundException(`no payment adapter for provider "${provider}"`);
return adapter;
}
has(provider: string): boolean {
return this.adapters.has(provider);
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { CashAdapter } from './cash.adapter';
import { PaymobAdapter } from './paymob.adapter';
import { SyriatelAdapter } from './syriatel.adapter';
import { MtnAdapter } from './mtn.adapter';
import { PaymentGatewayRegistry } from './payment-gateway.registry';
@Module({
providers: [CashAdapter, PaymobAdapter, SyriatelAdapter, MtnAdapter, PaymentGatewayRegistry],
exports: [PaymentGatewayRegistry],
})
export class PaymentGatewaysModule {}

Some files were not shown because too many files have changed in this diff Show More