diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index 8f3c07c..416be6f 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -32,15 +32,10 @@ services: - tripz-redisdata:/data networks: [tripz-net] - martin: - image: ghcr.io/maplibre/martin:latest - container_name: tripz-martin - restart: unless-stopped - # خادم بلاطات انطلق (يُهيّأ لاحقاً بمصدر البيانات). موجود من الآن للربط. - environment: - DATABASE_URL: postgres://${DB_USER:-tripz}:${DB_PASSWORD:-change_me_strong}@postgres:5432/${DB_NAME:-tripz} - depends_on: [postgres] - networks: [tripz-net] + # ملاحظة: كان هنا خادم بلاطات `martin`. أُزيل بقرار المالك 2026-07-18: + # **انطلق منصّة قائمة بذاتها** لها خوادمها وبلاطاتها؛ دورنا أن نرسل طلباً + # ونستقبل رداً، لا أن نستضيف خرائط. استضافتها عندنا كانت تعني قاعدة بيانات + # جغرافية ضخمة وصيانةً لا مقابل لها. (docs/25) api: build: . diff --git a/backend/src/config/configuration.ts b/backend/src/config/configuration.ts index 4b78527..f3427d9 100644 --- a/backend/src/config/configuration.ts +++ b/backend/src/config/configuration.ts @@ -52,7 +52,8 @@ export default () => ({ }, maps: { - tilesUrl: process.env.MAPS_TILES_URL ?? 'http://martin:3000', + // بلاطات انطلق تأتي من خوادم انطلق نفسها — لا نستضيفها (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', diff --git a/backend/src/database/migrations/1721940000000-RawSmsSettlement.ts b/backend/src/database/migrations/1721940000000-RawSmsSettlement.ts new file mode 100644 index 0000000..4a88041 --- /dev/null +++ b/backend/src/database/migrations/1721940000000-RawSmsSettlement.ts @@ -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 { + 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 { + await q.query(`DROP TABLE IF EXISTS tripz_pay_raw_sms`); + } +} diff --git a/backend/src/integrations/gemini/gemini.service.ts b/backend/src/integrations/gemini/gemini.service.ts index 8be7354..b86dac4 100644 --- a/backend/src/integrations/gemini/gemini.service.ts +++ b/backend/src/integrations/gemini/gemini.service.ts @@ -56,6 +56,33 @@ export class GeminiService { } } + /** + * يستخرج حقول تحويل مالي من نصّ رسالة المزوّد (docs/24 §5 — P2). + * + * `temperature: 0` وتعليماتٌ صريحة بإرجاع `null` عند عدم اليقين: النموذج + * الذي «يخمّن» مبلغاً غير مذكور يسوّي فاتورة بمال لم يصل. عدم المطابقة + * ومراجعةٌ بشرية أهون بكثير من تسويةٍ خاطئة. + */ + async extractTransferSms(body: string, provider: string): Promise { + 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 { if (!this.enabled) return { enabled: false }; diff --git a/backend/src/modules/payments/entities/raw-sms.entity.ts b/backend/src/modules/payments/entities/raw-sms.entity.ts new file mode 100644 index 0000000..6d16234 --- /dev/null +++ b/backend/src/modules/payments/entities/raw-sms.entity.ts @@ -0,0 +1,79 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, +} from 'typeorm'; + +export type SmsStatus = + | 'received' // وصلت وحُفظت خاماً — لم تُحلَّل بعد + | 'parsed' // استُخرجت حقولها ولم تُطابَق بعد + | 'matched' // طُوبقت بفاتورة وسُوّيت + | 'unmatched' // لا فاتورة تقابلها — طابور مراجعة بشرية + | 'duplicate' // وصلت مرتين — لا تُسوَّى ثانيةً + | 'failed'; // تعذّر التحليل + +/** + * سجلّ رسائل المزوّدين الخام (docs/24 §5 — P2). + * + * كليك وشام كاش **لا توفّران API**، فالتسوية تعتمد على رسالة التأكيد التي + * يستقبلها جهاز أندرويد مخصّص ويرفعها إلينا. + * + * **الرسالة تُحفظ خاماً دائماً وقبل أي تحليل.** سببان: التحليل قد يفشل أو + * يخطئ فنحتاج الأصل لإعادة المعالجة، وعند نزاعٍ مالي يكون النصّ الأصلي هو + * الحجّة لا تفسيرُنا له. الجدول **لا يُحدَّث حذفاً** — الحالة فقط تتغيّر. + */ +@Entity('pay_raw_sms') +@Index(['tenant_id', 'status']) +@Index(['tenant_id', 'received_at']) +// بصمة المحتوى تمنع معالجة نفس الرسالة مرتين لو أعاد الجهاز الإرسال. +@Index(['tenant_id', 'fingerprint'], { unique: true }) +export class RawSms { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + tenant_id: string; + + @Column() + provider: string; // cliq | shamcash | mtn | syriatel … + + /** المرسِل كما ظهر على الجهاز (اسم قصير أو رقم). */ + @Column({ type: 'varchar', nullable: true }) + sender: string | null; + + /** نصّ الرسالة **كما وصل** — لا يُقصّ ولا يُطبَّع. */ + @Column({ type: 'text' }) + body: string; + + /** sha256 للمزوّد+المرسل+النصّ — مفتاح منع التكرار. */ + @Column() + fingerprint: string; + + /** الجهاز الذي رفعها (لتتبّع أي هاتف توقّف عن الإرسال). */ + @Column({ type: 'varchar', nullable: true }) + device_id: string | null; + + @Column({ type: 'varchar', default: 'received' }) + status: SmsStatus; + + /** ما استخرجه Gemini: amount · reference · sender · currency. */ + @Column({ type: 'jsonb', default: {} }) + parsed: Record; + + /** الفاتورة التي طُوبقت بها، إن وُجدت. */ + @Column({ type: 'uuid', nullable: true }) + payment_id: string | null; + + /** سبب عدم المطابقة — يقرأه من يراجع الطابور. */ + @Column({ type: 'varchar', nullable: true }) + note: string | null; + + /** وقت وصول الرسالة إلى الجهاز (لا وقت رفعها إلينا). */ + @Column({ type: 'timestamptz', nullable: true }) + sent_at: Date | null; + + @CreateDateColumn() + received_at: Date; +} diff --git a/backend/src/modules/payments/payments.module.ts b/backend/src/modules/payments/payments.module.ts index 186675b..e620efe 100644 --- a/backend/src/modules/payments/payments.module.ts +++ b/backend/src/modules/payments/payments.module.ts @@ -2,10 +2,13 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Payment } from './entities/payment.entity'; import { Payout } from './entities/payout.entity'; +import { RawSms } from './entities/raw-sms.entity'; import { PaymentsService } from './payments.service'; import { PayoutsService } from './payouts.service'; +import { SmsSettlementService } from './sms-settlement.service'; import { PaymentsController } from './payments.controller'; import { PayoutsController } from './payouts.controller'; +import { SmsSettlementController } from './sms-settlement.controller'; import { WalletModule } from '../wallet/wallet.module'; import { UsersModule } from '../users/users.module'; import { TenantsModule } from '../tenants/tenants.module'; @@ -15,15 +18,15 @@ import { CreditModule } from '../credit/credit.module'; @Module({ // OtpModule و AuditModule عالميان. imports: [ - TypeOrmModule.forFeature([Payment, Payout]), + TypeOrmModule.forFeature([Payment, Payout, RawSms]), WalletModule, UsersModule, TenantsModule, TenantWalletModule, // دفترا المستأجر (docs/24) CreditModule, // شحن الرصيد التشغيلي للسائق ], - controllers: [PaymentsController, PayoutsController], - providers: [PaymentsService, PayoutsService], - exports: [PaymentsService, PayoutsService], + controllers: [PaymentsController, PayoutsController, SmsSettlementController], + providers: [PaymentsService, PayoutsService, SmsSettlementService], + exports: [PaymentsService, PayoutsService, SmsSettlementService], }) export class PaymentsModule {} diff --git a/backend/src/modules/payments/payments.service.ts b/backend/src/modules/payments/payments.service.ts index ae2e28f..9af22ef 100644 --- a/backend/src/modules/payments/payments.service.ts +++ b/backend/src/modules/payments/payments.service.ts @@ -95,6 +95,24 @@ export class PaymentsService { return { ok: true }; } + /** + * تسوية فاتورة من رسالة مزوّد (docs/24 §5 — P2). + * + * تمرّ بـ`markSuccess` نفسه عمداً: توجيه المال (إيراد/أمانة/رسم) منطقٌ + * واحد لا يُكرَّر هنا، وإلا تفرّع مساران ماليان وتناقضا عند أول تعديل. + */ + async settleFromSms(paymentId: string, reference: string): Promise { + const payment = await this.repo.findOne({ where: { id: paymentId } }); + if (!payment) throw new NotFoundException('payment not found'); + // حارس ثانٍ فوق بصمة الرسالة: فاتورة سُوّيت مرة لا تُسوَّى ثانيةً مهما + // تعدّدت الرسائل التي تشير إليها. + if (payment.status === 'success') return payment; + + payment.tx_ref = payment.tx_ref ?? reference; + payment.meta = { ...(payment.meta ?? {}), settled_via: 'sms', sms_reference: reference }; + return this.markSuccess(payment); + } + async findMine(tenantId: string, userId: string) { return this.repo.find({ where: { tenant_id: tenantId, user_id: userId }, diff --git a/backend/src/modules/payments/sms-settlement.controller.ts b/backend/src/modules/payments/sms-settlement.controller.ts new file mode 100644 index 0000000..a4ad94f --- /dev/null +++ b/backend/src/modules/payments/sms-settlement.controller.ts @@ -0,0 +1,71 @@ +import { + Body, + Controller, + Get, + Headers, + Param, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { Throttle } from '@nestjs/throttler'; +import { SmsSettlementService } from './sms-settlement.service'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator'; + +@ApiTags('sms-settlement') +@Controller('payments/sms') +export class SmsSettlementController { + constructor(private readonly settlement: SmsSettlementService) {} + + /** + * يرفع جهاز الأندرويد رسالة المزوّد (docs/24 §5 — P2). + * + * **بلا JWT** — الرافع جهازٌ لا مستخدم. المصادقة بسرّ لكل مستأجر في + * `x-sms-secret`، ويُقارَن بزمن ثابت داخل الخدمة. + * + * حدّ الطلبات أوسع من العام: جهاز واحد قد يرفع دفعةً متراكمة بعد انقطاع + * شبكة، فحدٌّ ضيّق يُسقط تأكيدات دفعٍ حقيقية. + */ + @Throttle({ default: { limit: 120, ttl: 60_000 } }) + @Post('webhook/:tenantSlug') + webhook( + @Param('tenantSlug') tenantSlug: string, + @Headers('x-sms-secret') secret: string, + @Body() body: any, + ) { + return this.settlement.ingest(tenantSlug, secret, { + provider: body?.provider, + body: body?.body, + sender: body?.sender, + deviceId: body?.device_id, + sentAt: body?.sent_at, + }); + } + + /** طابور المراجعة — ما لم يُطابَق آلياً (أدمن المستأجر). */ + @ApiBearerAuth() + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('admin') + @Get('review') + review(@CurrentUser() user: AuthUser, @Query('limit') limit?: string) { + const n = Number(limit); + return this.settlement.reviewQueue(user.tenantId, Number.isFinite(n) && n > 0 ? n : 100); + } + + /** ربط يدوي لرسالة بفاتورة بعد المراجعة. */ + @ApiBearerAuth() + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('admin') + @Post('review/:smsId/match') + match( + @CurrentUser() user: AuthUser, + @Param('smsId') smsId: string, + @Body('payment_id') paymentId: string, + ) { + return this.settlement.manualMatch(user.tenantId, smsId, paymentId); + } +} diff --git a/backend/src/modules/payments/sms-settlement.service.spec.ts b/backend/src/modules/payments/sms-settlement.service.spec.ts new file mode 100644 index 0000000..62ff9d2 --- /dev/null +++ b/backend/src/modules/payments/sms-settlement.service.spec.ts @@ -0,0 +1,185 @@ +import { UnauthorizedException, BadRequestException } from '@nestjs/common'; +import { SmsSettlementService } from './sms-settlement.service'; + +const TENANT_ID = 'tenant-uuid'; +const SECRET = 'super-secret-device-key'; + +/** مخزن رسائل في الذاكرة يحاكي تفرّد البصمة كما يفعل فهرس القاعدة. */ +function smsRepoStub() { + const rows: any[] = []; + return { + rows, + create: (x: any) => ({ ...x }), + save: async (x: any) => { + if (!x.id) { + x.id = `sms-${rows.length + 1}`; + rows.push(x); + } else { + const i = rows.findIndex((r) => r.id === x.id); + if (i >= 0) rows[i] = x; + } + return x; + }, + findOne: async ({ where }: any) => + rows.find( + (r) => + (!where.fingerprint || r.fingerprint === where.fingerprint) && + (!where.id || r.id === where.id) && + r.tenant_id === where.tenant_id, + ) ?? null, + find: async () => rows, + }; +} + +function makeService(opts: { + parsed?: any; + candidates?: any[]; + byRef?: any; + geminiEnabled?: boolean; + secret?: string | undefined; +} = {}) { + const sms = smsRepoStub(); + const payments = { + findOne: async ({ where }: any) => (where.tx_ref ? (opts.byRef ?? null) : (opts.byRef ?? null)), + find: async () => opts.candidates ?? [], + }; + const paymentsService = { settleFromSms: jest.fn().mockResolvedValue({}) }; + const tenants = { + resolve: jest.fn().mockResolvedValue({ + id: TENANT_ID, + settings: { payments: { sms_webhook_secret: 'secret' in opts ? opts.secret : SECRET } }, + }), + }; + const gemini = { + enabled: opts.geminiEnabled !== false, + extractTransferSms: jest.fn().mockResolvedValue(opts.parsed ?? {}), + }; + + const svc = new SmsSettlementService( + sms as any, + payments as any, + paymentsService as any, + tenants as any, + gemini as any, + ); + return { svc, sms, paymentsService, gemini }; +} + +const INBOUND = { + provider: 'cliq', + body: 'تم استلام مبلغ 25.000 دينار. رقم العملية CLQ-8891', + sender: 'CLIQ', +}; + +describe('SmsSettlementService — تسوية بالرسائل (docs/24 §5)', () => { + it('يرفض السرّ الخاطئ — رسالة مزوّرة تعني مالاً من عدم', async () => { + const { svc, paymentsService } = makeService(); + await expect(svc.ingest('siro', 'wrong-secret', INBOUND)).rejects.toThrow(UnauthorizedException); + expect(paymentsService.settleFromSms).not.toHaveBeenCalled(); + }); + + it('يرفض بلا سرّ إطلاقاً', async () => { + const { svc } = makeService(); + await expect(svc.ingest('siro', undefined, INBOUND)).rejects.toThrow(UnauthorizedException); + }); + + it('مستأجر بلا سرّ مضبوط = النقطة مغلقة لا مفتوحة', async () => { + const { svc } = makeService({ secret: undefined }); + await expect(svc.ingest('siro', 'anything', INBOUND)).rejects.toThrow(UnauthorizedException); + }); + + it('يحفظ الرسالة خاماً قبل أي تحليل', async () => { + const { svc, sms } = makeService({ parsed: { amount: null } }); + await svc.ingest('siro', SECRET, INBOUND); + expect(sms.rows[0].body).toBe(INBOUND.body); // بلا قصّ ولا تطبيع + }); + + it('التحليل الفاشل لا يُضيع الرسالة', async () => { + const { svc, sms } = makeService(); + const gemini = { enabled: true, extractTransferSms: jest.fn().mockRejectedValue(new Error('Gemini 503')) }; + (svc as any).gemini = gemini; + + const res = await svc.ingest('siro', SECRET, INBOUND); + expect(res.status).toBe('failed'); + expect(sms.rows[0].body).toBe(INBOUND.body); + }); + + it('نفس الرسالة مرتين لا تُسوّى مرتين', async () => { + const payment = { id: 'pay-1', status: 'pending', amount: 25 }; + const { svc, paymentsService } = makeService({ + parsed: { amount: 25, reference: 'CLQ-8891', is_incoming: true }, + byRef: payment, + }); + + const first = await svc.ingest('siro', SECRET, INBOUND); + const second = await svc.ingest('siro', SECRET, INBOUND); + + expect(first.duplicate).toBe(false); + expect(second.duplicate).toBe(true); + expect(paymentsService.settleFromSms).toHaveBeenCalledTimes(1); + }); + + it('يسوّي عند مطابقة المرجع', async () => { + const payment = { id: 'pay-9', status: 'pending', amount: 25 }; + const { svc, paymentsService } = makeService({ + parsed: { amount: 25, reference: 'CLQ-8891', is_incoming: true }, + byRef: payment, + }); + const res = await svc.ingest('siro', SECRET, INBOUND); + expect(res.status).toBe('matched'); + expect(paymentsService.settleFromSms).toHaveBeenCalledWith('pay-9', 'CLQ-8891'); + }); + + it('بلا مبلغ صريح لا يخمّن — يذهب للمراجعة', async () => { + const { svc, paymentsService } = makeService({ + parsed: { amount: null, reference: 'X', is_incoming: true }, + }); + const res = await svc.ingest('siro', SECRET, INBOUND); + expect(res.status).toBe('unmatched'); + expect(paymentsService.settleFromSms).not.toHaveBeenCalled(); + }); + + it('رسالة صادرة (لا استلام) لا تُسوّى', async () => { + const { svc, paymentsService } = makeService({ + parsed: { amount: 25, reference: 'X', is_incoming: false }, + }); + const res = await svc.ingest('siro', SECRET, INBOUND); + expect(res.status).toBe('unmatched'); + expect(paymentsService.settleFromSms).not.toHaveBeenCalled(); + }); + + it('فاتورتان بنفس المبلغ = التباس يُراجَع بشرياً لا تسويةٌ عشوائية', async () => { + const { svc, paymentsService } = makeService({ + parsed: { amount: 25, reference: null, is_incoming: true }, + byRef: null, + candidates: [{ id: 'a' }, { id: 'b' }], + }); + const res = await svc.ingest('siro', SECRET, INBOUND); + expect(res.status).toBe('unmatched'); + expect(paymentsService.settleFromSms).not.toHaveBeenCalled(); + }); + + it('مطابقة بالمبلغ حين تكون الفاتورة وحيدة', async () => { + const { svc, paymentsService } = makeService({ + parsed: { amount: 25, reference: null, is_incoming: true }, + byRef: null, + candidates: [{ id: 'only-one' }], + }); + const res = await svc.ingest('siro', SECRET, INBOUND); + expect(res.status).toBe('matched'); + expect(paymentsService.settleFromSms).toHaveBeenCalledWith('only-one', expect.any(String)); + }); + + it('بلا Gemini تُحفظ الرسالة وتنتظر بدل أن تُهمَل', async () => { + const { svc } = makeService({ geminiEnabled: false }); + const res = await svc.ingest('siro', SECRET, INBOUND); + expect(res.status).toBe('received'); + }); + + it('يرفض جسماً ناقصاً', async () => { + const { svc } = makeService(); + await expect(svc.ingest('siro', SECRET, { provider: 'cliq', body: '' })).rejects.toThrow( + BadRequestException, + ); + }); +}); diff --git a/backend/src/modules/payments/sms-settlement.service.ts b/backend/src/modules/payments/sms-settlement.service.ts new file mode 100644 index 0000000..83f549d --- /dev/null +++ b/backend/src/modules/payments/sms-settlement.service.ts @@ -0,0 +1,225 @@ +import { BadRequestException, Injectable, Logger, UnauthorizedException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, Between } from 'typeorm'; +import { createHash, timingSafeEqual } from 'crypto'; +import { RawSms } from './entities/raw-sms.entity'; +import { Payment } from './entities/payment.entity'; +import { PaymentsService } from './payments.service'; +import { TenantsService } from '../tenants/tenants.service'; +import { GeminiService } from '../../integrations/gemini/gemini.service'; + +export interface InboundSms { + provider: string; + body: string; + sender?: string; + deviceId?: string; + sentAt?: string; +} + +/** + * التسوية من رسائل المزوّد (docs/24 §5 — P2) — للأسواق بلا API (كليك · شام كاش). + * + * التدفّق: جهاز أندرويد يلتقط رسالة التأكيد → يرفعها **خاماً** → نحفظها فوراً + * → Gemini يستخرج المبلغ والمرجع → نطابقها بفاتورة معلّقة → تُسوَّى مرة واحدة. + * + * ⚠️ **هذه النقطة تصنع المال.** رسالةٌ مزوَّرة مقبولة = رصيدٌ من عدم. لذلك: + * سرٌّ لكل مستأجر بمقارنة ثابتة الزمن · بصمة محتوى تمنع إعادة الإرسال · + * مطابقة بالمرجع لا بالمبلغ وحده · وكل ما لا يُطابَق **يُحجز للمراجعة** لا + * يُسوَّى تفاؤلاً. + */ +@Injectable() +export class SmsSettlementService { + private readonly logger = new Logger('SmsSettlement'); + + constructor( + @InjectRepository(RawSms) private readonly sms: Repository, + @InjectRepository(Payment) private readonly payments: Repository, + private readonly paymentsService: PaymentsService, + private readonly tenants: TenantsService, + private readonly gemini: GeminiService, + ) {} + + /** + * يتحقّق من سرّ الجهاز الرافع. المقارنة **ثابتة الزمن**: المقارنة النصّية + * العادية تنتهي عند أول حرف مختلف، فيتسرّب طول البادئة الصحيحة زمنياً + * ويمكن استخراج السرّ حرفاً حرفاً. + */ + private assertSecret(tenantSecret: string | undefined, provided: string | undefined) { + if (!tenantSecret) { + // بلا سرّ مضبوط تبقى النقطة **مغلقة**، لا مفتوحة. الافتراض المتساهل هنا + // يعني أن أي مستأجر لم يُهيَّأ بعد تُقبل له رسائل مجهولة المصدر. + throw new UnauthorizedException('sms webhook not configured for this tenant'); + } + const a = Buffer.from(String(provided ?? '')); + const b = Buffer.from(tenantSecret); + if (a.length !== b.length || !timingSafeEqual(a, b)) { + throw new UnauthorizedException('invalid sms webhook secret'); + } + } + + private fingerprint(provider: string, sender: string | null, body: string): string { + return createHash('sha256').update(`${provider}|${sender ?? ''}|${body}`).digest('hex'); + } + + /** + * استقبال رسالة. **الحفظ أولاً، التحليل بعده**: لو انهار التحليل أو تعطّل + * Gemini يجب ألّا نفقد الرسالة — يمكن إعادة معالجتها لاحقاً من السجل. + */ + async ingest(tenantSlug: string, secret: string | undefined, dto: InboundSms) { + if (!dto?.body || !dto?.provider) { + throw new BadRequestException('provider and body are required'); + } + const tenant = await this.tenants.resolve(tenantSlug); + if (!tenant) throw new UnauthorizedException('unknown tenant'); + this.assertSecret(tenant.settings?.payments?.sms_webhook_secret, secret); + + const sender = dto.sender ?? null; + const fingerprint = this.fingerprint(dto.provider, sender, dto.body); + + const existing = await this.sms.findOne({ + where: { tenant_id: tenant.id, fingerprint }, + }); + if (existing) { + this.logger.warn(`رسالة مكرّرة تُجوهل: ${existing.id}`); + return { id: existing.id, status: existing.status, duplicate: true }; + } + + let row = await this.sms.save( + this.sms.create({ + tenant_id: tenant.id, + provider: dto.provider, + sender, + body: dto.body, + fingerprint, + device_id: dto.deviceId ?? null, + sent_at: dto.sentAt ? new Date(dto.sentAt) : null, + status: 'received', + }), + ); + + // التحليل والمطابقة لا يُفشلان الاستقبال: الجهاز تلقّى «حُفظت» بالفعل، + // وأي خطأ هنا يترك الرسالة في الطابور بدل أن يدفع الجهاز لإعادة الإرسال. + try { + row = await this.process(row); + } catch (e: any) { + this.logger.error(`تعذّرت معالجة ${row.id}: ${e?.message}`); + row.status = 'failed'; + row.note = String(e?.message ?? 'processing error').slice(0, 200); + row = await this.sms.save(row); + } + + return { id: row.id, status: row.status, payment_id: row.payment_id, duplicate: false }; + } + + /** يحلّل رسالة محفوظة ويحاول مطابقتها. قابل لإعادة التشغيل على أي صفّ. */ + async process(row: RawSms): Promise { + if (!this.gemini.enabled) { + row.status = 'received'; + row.note = 'GEMINI_API_KEY غير مضبوط — بانتظار المعالجة'; + return this.sms.save(row); + } + + const parsed = await this.gemini.extractTransferSms(row.body, row.provider); + row.parsed = parsed ?? {}; + + const amount = Number(parsed?.amount); + const reference = parsed?.reference ? String(parsed.reference).trim() : null; + + if (parsed?.is_incoming === false) { + row.status = 'unmatched'; + row.note = 'ليست رسالة استلام'; + return this.sms.save(row); + } + if (!Number.isFinite(amount) || amount <= 0) { + // النموذج لم يجد مبلغاً صريحاً — نتوقّف بدل التخمين. + row.status = 'unmatched'; + row.note = 'لا مبلغ صريح في الرسالة'; + return this.sms.save(row); + } + + const payment = await this.findMatch(row.tenant_id, row.provider, amount, reference); + if (!payment) { + row.status = 'unmatched'; + row.note = `لا فاتورة معلّقة تطابق ${amount}${reference ? ` (مرجع ${reference})` : ''}`; + return this.sms.save(row); + } + + // التسوية تمرّ بنفس مسار الدفع العادي، فينطبق توجيه المال ذاته + // (إيراد/أمانة/رسم) بلا منطق مالي ثانٍ يتفرّع هنا ويتناقض لاحقاً. + await this.paymentsService.settleFromSms(payment.id, reference ?? row.id); + + row.status = 'matched'; + row.payment_id = payment.id; + row.note = null; + return this.sms.save(row); + } + + /** + * المطابقة: المرجع أولاً (قاطع)، ثم المبلغ ضمن نافذة زمنية. + * + * المطابقة بالمبلغ وحده خطرة — فاتورتان بنفس المبلغ تجعل الرسالة تسوّي + * الخطأ منهما. لذلك نشترط **فاتورة واحدة فقط** مطابقة؛ التعدّد يذهب + * للمراجعة البشرية. + */ + private async findMatch( + tenantId: string, + provider: string, + amount: number, + reference: string | null, + ): Promise { + if (reference) { + const byRef = await this.payments.findOne({ + where: { tenant_id: tenantId, status: 'pending', tx_ref: reference }, + }); + if (byRef) return byRef; + } + + // نافذة 24 ساعة: فاتورة أقدم من ذلك غالباً متروكة، ومطابقتها بمبلغ + // متشابه تربط دفعةً جديدة بفاتورة قديمة خطأً. + const since = new Date(Date.now() - 24 * 3600_000); + const candidates = await this.payments.find({ + where: { + tenant_id: tenantId, + status: 'pending', + provider, + amount: amount as any, + created_at: Between(since, new Date()) as any, + }, + take: 2, + }); + + if (candidates.length === 1) return candidates[0]; + return null; // صفر = لا مطابقة · أكثر من واحدة = التباس يُراجَع بشرياً + } + + /** طابور المراجعة: ما لم يُطابَق آلياً — لا يُهمَل بصمت. */ + async reviewQueue(tenantId: string, limit = 100) { + return this.sms.find({ + where: [ + { tenant_id: tenantId, status: 'unmatched' }, + { tenant_id: tenantId, status: 'failed' }, + ], + order: { received_at: 'DESC' }, + take: Math.min(limit, 500), + }); + } + + /** ربط يدوي من طابور المراجعة (أدمن المستأجر). */ + async manualMatch(tenantId: string, smsId: string, paymentId: string) { + const row = await this.sms.findOne({ where: { id: smsId, tenant_id: tenantId } }); + if (!row) throw new BadRequestException('sms not found'); + if (row.status === 'matched') throw new BadRequestException('already matched'); + + const payment = await this.payments.findOne({ + where: { id: paymentId, tenant_id: tenantId }, + }); + if (!payment) throw new BadRequestException('payment not found'); + if (payment.status === 'success') throw new BadRequestException('payment already settled'); + + await this.paymentsService.settleFromSms(payment.id, row.parsed?.reference ?? row.id); + row.status = 'matched'; + row.payment_id = payment.id; + row.note = 'مطابقة يدوية'; + return this.sms.save(row); + } +} diff --git a/docs/24-tenant-wallet-revenue.md b/docs/24-tenant-wallet-revenue.md index 78866db..8065c52 100644 --- a/docs/24-tenant-wallet-revenue.md +++ b/docs/24-tenant-wallet-revenue.md @@ -84,7 +84,16 @@ - `GET /admin/wallet/summary` و`/revenue-by-reason` لأدمن المستأجر (نطاقه من التوكن). - `/admin/overview` يفصل `trip_commission` عن `revenue` الكلي. -**الباقي**: P1 (بوابات فعلية) · P2 (تسوية بالرسائل) · P4 (تقارير أوسع) · وسحب المالك أرباحه من `tenant_wallet`. +**منجَز أيضاً (P2 — التسوية بالرسائل):** +- `tripz_pay_raw_sms`: الرسالة تُحفظ **خاماً وقبل أي تحليل**، وفهرس فريد على بصمة المحتوى يمنع معالجة إعادة الإرسال مرتين. +- `POST /payments/sms/webhook/:tenantSlug` — بلا JWT (الرافع جهاز)، محميّ بسرّ لكل مستأجر (`settings.payments.sms_webhook_secret`) بمقارنة **ثابتة الزمن**؛ ومستأجر بلا سرّ = النقطة **مغلقة** لا مفتوحة. +- `GeminiService.extractTransferSms` بحرارة صفر وتعليمات صريحة بإرجاع `null` عند عدم اليقين — النموذج الذي يخمّن مبلغاً يسوّي فاتورة بمال لم يصل. +- المطابقة: المرجع أولاً، ثم المبلغ ضمن 24 ساعة و**بشرط فاتورة وحيدة**؛ فاتورتان بنفس المبلغ = التباس يُراجَع بشرياً. +- التسوية تمرّ بـ`settleFromSms` → `markSuccess` نفسه، فلا مسار مالي ثانٍ يتفرّع ويتناقض. +- `GET /payments/sms/review` + `POST /payments/sms/review/:id/match` لطابور المراجعة والربط اليدوي. +- 13 اختباراً تغطّي التزوير والتكرار والتخمين والالتباس. + +**الباقي**: P1 (بوابات فعلية) · P4 (تقارير أوسع) · وسحب المالك أرباحه من `tenant_wallet`. ## 7. أثره على ما هو مبنيّ الآن diff --git a/docs/25-infrastructure-topology.md b/docs/25-infrastructure-topology.md new file mode 100644 index 0000000..d5e2e40 --- /dev/null +++ b/docs/25-infrastructure-topology.md @@ -0,0 +1,114 @@ +# 25 — بنية السيرفرات والحاويات (التنظيم الكامل) + +> إجابات أسئلة المالك 2026-07-18: كم حاوية؟ هل الدفع منفصل؟ أين السوبر-أدمن؟ ما حجم السيرفر؟ كيف ننقل مستأجراً؟ +> ذو صلة: [14](14-server-conventions.md) · [15](15-deploy-flow.md) · [20](20-tls.md) · [24](24-tenant-wallet-revenue.md). + +--- + +## 1. الحاويات: أربع، والباك إند **واحد** لا مفكَّك + +| الحاوية | الصورة | الدور | +|---|---|---| +| `tripz-api` | مبنيّة محلياً | كل الـHTTP + WebSocket | +| `tripz-worker` | **نفس الصورة**، أمر مختلف | المهام المجدولة والطوابير (BullMQ) | +| `tripz-postgres` | postgis/postgis:16 | القاعدة | +| `tripz-redis` | redis:7-alpine | الكاش · المطابقة · المواقع · الطوابير | + +**الدفع ليس حاوية منفصلة، وهذا مقصود.** المدفوعات وحدة (module) داخل نفس التطبيق تشارك القاعدة والمعاملة (transaction). فصلها إلى خدمة مستقلة يعني أن تسوية دفعةٍ تكتب في محفظتين عبر الشبكة بلا معاملة واحدة — أي انهيارٌ في المنتصف يترك مالاً نصف مسوّى. الوحدة الذرّية للمال هي معاملة قاعدة واحدة، ولا تُقطَع بحدود شبكة إلا لضرورة قاهرة. حين يكبر الحمل نُشغّل **نسخاً أكثر من نفس الـAPI**، لا خدمات مجزَّأة. + +**`tripz-martin` أُزيلت** (قرار المالك): انطلق منصّة قائمة بذاتها لها خوادمها وبلاطاتها. دورنا طلبٌ وردّ — لا استضافة خرائط ولا قاعدة جغرافية ضخمة نصونها بلا مقابل. + +## 2. أين يقع كل شيء + +``` + ┌──────────── سيرفر المنصّة (Control Plane) ────────────┐ + │ tripz-api (سوبر-أدمن فقط) · postgres · redis │ +تطبيقات ولوحات ──────┤ admin.tripz.com ← لوحة السوبر-أدمن │ + └───────────────────────┬──────────────────────────────┘ + │ HTTPS + سرّ المنصّة + ┌────────────────────────────────────┼────────────────────────────────┐ + │ │ │ +┌───────▼────────┐ ┌────────▼───────┐ ┌─────────▼──────┐ +│ سيرفر مشترك │ │ سيرفر سيادي │ │ سيرفر مستأجر │ +│ عدة مستأجرين │ │ مستأجر واحد │ │ آخر… │ +│ api·worker·db │ │ api·worker·db │ │ │ +└────────────────┘ └─────────────────┘ └────────────────┘ +``` + +كل صندوق يشغّل **نفس الأربع حاويات**. الفرق في `.env` وحده: أي قاعدة، أي بادئة، أي مستأجرين. + +## 3. سيرفر السوبر-أدمن المنفصل — كما طلبت + +**لماذا منفصل**: سرّ المنصّة (`PLATFORM_SECRET`) يفتح كل المستأجرين. بقاؤه على صندوق يشاركه مستأجرٌ يعني أن اختراق مستأجر واحد = اختراق المنصّة كلها. الفصل يجعل نطاق أي اختراق مستأجراً واحداً. + +**الإعداد**: +1. صندوق جديد (4 vCPU · 8GB يكفي — لا يحمل رحلات). +2. نفس `docker compose` بـ`.env` خاص: قاعدته الخاصة، و`PLATFORM_SECRET` **موجود هنا فقط**. +3. نطاق فرعي على دومينك: `admin.tripz.com` → Nginx يُنهي TLS ويوجّه إلى `127.0.0.1:4010`. +4. **يُحذف `PLATFORM_SECRET` من سيرفرات المستأجرين** — بلا هذا يبقى الفصل شكلياً. + +**نقطة تحتاج قراراً**: السوبر-أدمن يقرأ اليوم من **قاعدته المحلية**. مع سيرفرات متعدّدة لن يرى أرقام المستأجرين البعيدين تلقائياً. خياران: +- **(أ) دفع دوري**: كل سيرفر مستأجر يرسل ملخّصه (رحلات · GMV · إيراد) إلى المنصّة كل ساعة. بسيط، والأرقام متأخّرة ساعة — وهذا مقبول لأنها أرقام إدارية لا تشغيلية. **توصيتي.** +- **(ب) سحب حيّ**: المنصّة تنادي كل سيرفر عند فتح اللوحة. أرقام لحظية، لكن سيرفراً متوقّفاً يُعطّل اللوحة كلها. + +## 4. WebSocket بدل الاستطلاع — وأثره العددي + +في سيرو كان السائق **يستطلع** (polling) بحثاً عن طلبات، فكل سائق يولّد طلباً كل بضع ثوانٍ سواء وُجد عمل أم لا. مع 1000 سائق واستطلاع كل 3 ثوانٍ = **333 طلب/ثانية دائمة بلا أي رحلة**. + +عندنا: اتصال WebSocket واحد يبقى مفتوحاً، ولا تمرّ بيانات إلا عند **حدث فعلي**. نفس الألف سائق = صفر طلب في الهدوء. هذا الفرق وحده هو سبب اتّساع الطاقة. + +**الغرف المبنيّة** (`realtime.gateway.ts`): +- `tenant:{id}:user:{userId}` — إشعارات المستخدم. +- `tenant:{id}:drivers` — بثّ لكل السائقين المتصلين. +- `tenant:{id}:trip:{tripId}` — التتبّع والدردشة والمكالمة للطرفين. + +**«الرحلات المتاحة» (طلبك)**: تُبنى فوق `tenant:{id}:drivers` القائمة، مع غرفة لكل منطقة (`tenant:{id}:zone:{zoneId}`) حتى لا يُبثّ عرضٌ في عمّان إلى سائق في حلب. **بند مفتوح — يُنفَّذ عند المجموعة التالية.** + +**التوسّع الأفقي جاهز**: `redis-io.adapter` مبنيٌّ أصلاً، فنسخ الـAPI المتعدّدة تتشارك الغرف عبر Redis. سائقٌ متصل بالنسخة أ يستقبل حدثاً بثّته النسخة ب. + +## 5. الطاقة والحجم — بالأرقام + +اختبار الحمل (docs/22 §1.2) على **صندوق مشترك مزدحم أصلاً**: ≈ **1.15 مليون رحلة/يوم**، أي **115 ضعف** هدف الـ10 آلاف/يوم. + +| الهدف اليومي | الحجم الكافي | ملاحظة | +|---|---|---| +| حتى 10 آلاف رحلة | 4 vCPU · 8GB | الوضع الحالي — فائض كبير | +| حتى 100 ألف | 8 vCPU · 16GB | صندوق مخصّص لا مشترك | +| حتى مليون | 8–16 vCPU · 32GB · NVMe | حدّ الصندوق الواحد | +| فوق مليون | نسختا API + فصل القاعدة | التقسيم يبدأ هنا لا قبله | + +**وقت الذروة هو المقياس لا المعدّل اليومي**: مليون رحلة/يوم ليست 11.5 رحلة/ثانية موزّعة بالتساوي؛ الذروة (7–9 صباحاً · 4–7 مساءً) تحمل ~٢٥٪ من اليوم في ساعتين، أي ≈ 35 رحلة/ثانية. الحجم يُقاس على هذا. + +**العنق دائماً Postgres (بركة الاتصالات) لا الـCPU.** أول ما يبطؤ النظام، ارفع البركة وافحص الاستعلامات قبل أن تشتري معالجات. Redis 4–8GB يكفي لأن ما فيه نصوص صغيرة. + +## 6. نقل مستأجر إلى سيرفر خاص + +لأن كل شيء في حاويات وكل مستأجر معزول ببادئة، النقل ميكانيكي: + +1. صندوق جديد + `git clone` + `.env` خاص به. +2. `docker compose up -d --build` ثم `npm run migration:run`. +3. تصدير بيانات المستأجر من القاعدة القديمة واستيرادها. +4. `POST /admin/tenants/provision` أو نقل صفّه. +5. توجيه نطاقه الفرعي إلى الصندوق الجديد. +6. **تعليق المستأجر على الصندوق القديم** (`PATCH /admin/tenants/:id/status`) قبل التبديل — يمنع كتابةً جديدة على القاعدة القديمة أثناء النقل، وهي أخطر لحظة في العملية كلها. + +## 7. التوسّع بالترتيب — لا تقفز خطوة + +1. **رأسياً**: كبّر الصندوق. أرخص وأبسط ويكفي حتى ~مليون/يوم. +2. **نسخ API**: `docker compose up -d --scale api=3` خلف Nginx. الكود بلا حالة والـRedis adapter جاهز. +3. **فصل القاعدة**: Postgres على صندوق خاص + نسخة قراءة للتقارير. +4. **فصل Redis**: نادراً ما يلزم. +5. **التقسيم (sharding)**: لا داعي له في الأفق المنظور — بحث الحمل حسمها. + +## 8. الاحتياطي + +نسخة Postgres متدفّقة (streaming replica) + Redis AOF على صندوق ثانٍ. `RPO ≈ ثوانٍ`، `RTO ≈ دقائق` (وقت إقلاع compose). +⚠️ نسخة احتياطية لم تُختبَر استعادتها ليست نسخة احتياطية. **بند مفتوح: تمرين استعادة دوري.** + +--- + +## 9. بنود مفتوحة من هذا المستند +- غرف «الرحلات المتاحة» حسب المنطقة (§4). +- آلية تجميع أرقام المستأجرين للسوبر-أدمن — الخيار (أ) الموصى به (§3). +- تمرين استعادة النسخة الاحتياطية (§8). +- إزالة `PLATFORM_SECRET` من سيرفرات المستأجرين عند فصل المنصّة (§3).