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:
Hamza-Ayed
2026-07-18 15:19:17 +03:00
co-authored by Claude Fable 5
parent b1a060c5ed
commit da035e46a4
12 changed files with 789 additions and 15 deletions
+4 -9
View File
@@ -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: .
+2 -1
View File
@@ -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',
@@ -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> {
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 { 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 {}
@@ -95,6 +95,24 @@ export class PaymentsService {
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) {
return this.repo.find({
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);
}
}
+10 -1
View File
@@ -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. أثره على ما هو مبنيّ الآن
+114
View File
@@ -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).