feat: P2 — التسوية بالرسائل + إزالة مارتن + مستند البنية (docs/25)
P2 (كليك/شام كاش بلا API): - الرسالة تُحفظ خاماً **قبل** أي تحليل: التحليل قد يفشل فنحتاج الأصل لإعادة المعالجة، وعند النزاع يكون النصّ الأصلي هو الحجّة لا تفسيرُنا له. - النقطة تصنع المال، فرسالة مزوّرة = رصيد من عدم. الحماية: سرّ لكل مستأجر بمقارنة ثابتة الزمن (المقارنة النصّية تسرّب السرّ حرفاً حرفاً زمنياً)، وبصمة محتوى فريدة تمنع احتساب إعادة الإرسال مرتين. - Gemini بحرارة صفر ومطالَب بإرجاع null عند عدم اليقين: نموذج يخمّن مبلغاً يسوّي فاتورة بمال لم يصل. بلا مبلغ صريح → مراجعة بشرية لا تسوية. - المطابقة بالمرجع أولاً، ثم بالمبلغ خلال 24 ساعة وبشرط فاتورة وحيدة — فاتورتان بنفس المبلغ التباسٌ يُراجَع، لا تسويةٌ عشوائية لإحداهما. - التسوية تمرّ بـmarkSuccess نفسه فلا يتفرّع مسار مالي ثانٍ. إزالة حاوية martin: انطلق منصّة قائمة بذاتها لها خوادمها؛ دورنا طلب وردّ لا استضافة خرائط (قرار المالك). docs/25: جرد الحاويات · لماذا الدفع ليس خدمة منفصلة · سيرفر السوبر-أدمن المنفصل ونطاقه الفرعي · WebSocket مقابل الاستطلاع بالأرقام · أحجام السيرفرات على أساس الذروة لا المعدّل · نقل مستأجر · ترتيب التوسّع. 13 اختباراً جديداً (201 إجمالاً، كلها خضراء). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
b1a060c5ed
commit
da035e46a4
@@ -32,15 +32,10 @@ services:
|
|||||||
- tripz-redisdata:/data
|
- tripz-redisdata:/data
|
||||||
networks: [tripz-net]
|
networks: [tripz-net]
|
||||||
|
|
||||||
martin:
|
# ملاحظة: كان هنا خادم بلاطات `martin`. أُزيل بقرار المالك 2026-07-18:
|
||||||
image: ghcr.io/maplibre/martin:latest
|
# **انطلق منصّة قائمة بذاتها** لها خوادمها وبلاطاتها؛ دورنا أن نرسل طلباً
|
||||||
container_name: tripz-martin
|
# ونستقبل رداً، لا أن نستضيف خرائط. استضافتها عندنا كانت تعني قاعدة بيانات
|
||||||
restart: unless-stopped
|
# جغرافية ضخمة وصيانةً لا مقابل لها. (docs/25)
|
||||||
# خادم بلاطات انطلق (يُهيّأ لاحقاً بمصدر البيانات). موجود من الآن للربط.
|
|
||||||
environment:
|
|
||||||
DATABASE_URL: postgres://${DB_USER:-tripz}:${DB_PASSWORD:-change_me_strong}@postgres:5432/${DB_NAME:-tripz}
|
|
||||||
depends_on: [postgres]
|
|
||||||
networks: [tripz-net]
|
|
||||||
|
|
||||||
api:
|
api:
|
||||||
build: .
|
build: .
|
||||||
|
|||||||
@@ -52,7 +52,8 @@ export default () => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
maps: {
|
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',
|
provider: process.env.MAPS_PROVIDER ?? 'antlaq',
|
||||||
// خرائط انطلق (map-saas). مفتاح افتراضي + مفاتيح لكل دولة.
|
// خرائط انطلق (map-saas). مفتاح افتراضي + مفاتيح لكل دولة.
|
||||||
baseUrl: process.env.MAPS_BASE_URL ?? 'https://map-saas.intaleqapp.com',
|
baseUrl: process.env.MAPS_BASE_URL ?? 'https://map-saas.intaleqapp.com',
|
||||||
|
|||||||
@@ -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`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,6 +56,33 @@ export class GeminiService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* يستخرج حقول تحويل مالي من نصّ رسالة المزوّد (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> {
|
async extractDocument(image: GeminiImage, docType: string): Promise<any> {
|
||||||
if (!this.enabled) return { enabled: false };
|
if (!this.enabled) return { enabled: false };
|
||||||
|
|||||||
@@ -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<string, any>;
|
||||||
|
|
||||||
|
/** الفاتورة التي طُوبقت بها، إن وُجدت. */
|
||||||
|
@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;
|
||||||
|
}
|
||||||
@@ -2,10 +2,13 @@ import { Module } from '@nestjs/common';
|
|||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { Payment } from './entities/payment.entity';
|
import { Payment } from './entities/payment.entity';
|
||||||
import { Payout } from './entities/payout.entity';
|
import { Payout } from './entities/payout.entity';
|
||||||
|
import { RawSms } from './entities/raw-sms.entity';
|
||||||
import { PaymentsService } from './payments.service';
|
import { PaymentsService } from './payments.service';
|
||||||
import { PayoutsService } from './payouts.service';
|
import { PayoutsService } from './payouts.service';
|
||||||
|
import { SmsSettlementService } from './sms-settlement.service';
|
||||||
import { PaymentsController } from './payments.controller';
|
import { PaymentsController } from './payments.controller';
|
||||||
import { PayoutsController } from './payouts.controller';
|
import { PayoutsController } from './payouts.controller';
|
||||||
|
import { SmsSettlementController } from './sms-settlement.controller';
|
||||||
import { WalletModule } from '../wallet/wallet.module';
|
import { WalletModule } from '../wallet/wallet.module';
|
||||||
import { UsersModule } from '../users/users.module';
|
import { UsersModule } from '../users/users.module';
|
||||||
import { TenantsModule } from '../tenants/tenants.module';
|
import { TenantsModule } from '../tenants/tenants.module';
|
||||||
@@ -15,15 +18,15 @@ import { CreditModule } from '../credit/credit.module';
|
|||||||
@Module({
|
@Module({
|
||||||
// OtpModule و AuditModule عالميان.
|
// OtpModule و AuditModule عالميان.
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([Payment, Payout]),
|
TypeOrmModule.forFeature([Payment, Payout, RawSms]),
|
||||||
WalletModule,
|
WalletModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
TenantsModule,
|
TenantsModule,
|
||||||
TenantWalletModule, // دفترا المستأجر (docs/24)
|
TenantWalletModule, // دفترا المستأجر (docs/24)
|
||||||
CreditModule, // شحن الرصيد التشغيلي للسائق
|
CreditModule, // شحن الرصيد التشغيلي للسائق
|
||||||
],
|
],
|
||||||
controllers: [PaymentsController, PayoutsController],
|
controllers: [PaymentsController, PayoutsController, SmsSettlementController],
|
||||||
providers: [PaymentsService, PayoutsService],
|
providers: [PaymentsService, PayoutsService, SmsSettlementService],
|
||||||
exports: [PaymentsService, PayoutsService],
|
exports: [PaymentsService, PayoutsService, SmsSettlementService],
|
||||||
})
|
})
|
||||||
export class PaymentsModule {}
|
export class PaymentsModule {}
|
||||||
|
|||||||
@@ -95,6 +95,24 @@ export class PaymentsService {
|
|||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* تسوية فاتورة من رسالة مزوّد (docs/24 §5 — P2).
|
||||||
|
*
|
||||||
|
* تمرّ بـ`markSuccess` نفسه عمداً: توجيه المال (إيراد/أمانة/رسم) منطقٌ
|
||||||
|
* واحد لا يُكرَّر هنا، وإلا تفرّع مساران ماليان وتناقضا عند أول تعديل.
|
||||||
|
*/
|
||||||
|
async settleFromSms(paymentId: string, reference: string): Promise<Payment> {
|
||||||
|
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) {
|
async findMine(tenantId: string, userId: string) {
|
||||||
return this.repo.find({
|
return this.repo.find({
|
||||||
where: { tenant_id: tenantId, user_id: userId },
|
where: { tenant_id: tenantId, user_id: userId },
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<RawSms>,
|
||||||
|
@InjectRepository(Payment) private readonly payments: Repository<Payment>,
|
||||||
|
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<RawSms> {
|
||||||
|
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<Payment | null> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -84,7 +84,16 @@
|
|||||||
- `GET /admin/wallet/summary` و`/revenue-by-reason` لأدمن المستأجر (نطاقه من التوكن).
|
- `GET /admin/wallet/summary` و`/revenue-by-reason` لأدمن المستأجر (نطاقه من التوكن).
|
||||||
- `/admin/overview` يفصل `trip_commission` عن `revenue` الكلي.
|
- `/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. أثره على ما هو مبنيّ الآن
|
## 7. أثره على ما هو مبنيّ الآن
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
||||||
Reference in New Issue
Block a user