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
|
||||
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: .
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user