feat: المجموعة A (زمن حقيقي + FCM + Redis) + إصلاح سباق المحفظة

المجموعة A (docs/17):
- A1: FCM على كل انتقال حالة (priority high) + حذف التوكنات الميتة
- A2: common/i18n (ar/en) + عمود users.language — الإشعارات بلغة المستخدم
- A3: TripStateService — حالة الرحلة الجارية في Redis hash (TTL 6س)؛
  الانتقال صار UPDATE شرطي + قراءة واحدة بدل ~5 استعلامات
- A4: قبول ذرّي — CAS بـLua في Redis + UPDATE ... WHERE status='searching'
  كحَكَم نهائي؛ أول سائق يفوز والباقي يُرفضون بلا لمس القاعدة
- A5: مجموعة العروض في Redis + بث trip:offer_taken و FCM لبقية السائقين
- A6: GET /trips/available — السائق يسحب الطلبات القريبة
- A7: FCM data-only بحمولة كاملة للـoverlay

I1 — إصلاح سباق المحفظة (ثغرة مالية):
- credit/debit كانا read-modify-write على balance بلا قفل → خصمان متزامنان
  يكتبان فوق بعضهما. صارا UPDATE ذرّي واحد بشرط balance >= :amount،
  والقيد+الرصيد في معاملة واحدة
- wallet_txns.balance_after للتدقيق + CHECK (balance >= 0) كشبكة أمان
- إنشاء المحفظة عبر ON CONFLICT DO NOTHING (سباق ثانٍ كان كامناً)

الاختبارات تعمل على السيرفر (docs/15):
- npm test صار جزءاً من مرحلة builder — فشل اختبار = فشل بناء = لا نشر
- pg-mem + ioredis-mock: بلا شبكة وبلا قاعدة حقيقية
- scripts/wallet-race-test.mjs للتزامن الحقيقي على السيرفر

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hamza-Ayed
2026-07-17 02:23:00 +03:00
co-authored by Claude Opus 4.8
parent b7e91e5de6
commit 9d6b752ea8
28 changed files with 1372 additions and 166 deletions
@@ -22,6 +22,10 @@ export class WalletTxn {
@Column({ type: 'numeric', precision: 12, scale: 3 })
amount: number;
// الرصيد بعد تطبيق هذه الحركة — يجعل الدفتر قابلاً للتدقيق ومطابقة الرصيد.
@Column({ type: 'numeric', precision: 12, scale: 3, nullable: true })
balance_after: number | null;
@Column()
type: string; // credit | debit
@@ -0,0 +1,132 @@
import { randomUUID } from 'crypto';
import { newDb } from 'pg-mem';
import { DataSource } from 'typeorm';
import { BadRequestException } from '@nestjs/common';
import { Wallet } from './entities/wallet.entity';
import { WalletTxn } from './entities/wallet-txn.entity';
import { WalletService } from './wallet.service';
/**
* يعمل على Postgres في الذاكرة (pg-mem) — يثبت أن SQL المولَّد صحيح وأن دلالة
* الخصم المشروط تعمل. التزامن الحقيقي (عمليات متوازية على نفس الصف) لا يمكن
* إثباته هنا لأن pg-mem أحادي الخيط — لذلك scripts/wallet-race-test.mjs
* يُشغَّل على السيرفر مقابل Postgres حقيقي.
*/
const TENANT = '11111111-1111-1111-1111-111111111111';
const USER = '22222222-2222-2222-2222-222222222222';
describe('WalletService', () => {
let ds: DataSource;
let wallet: WalletService;
beforeEach(async () => {
const db = newDb({ autoCreateForeignKeyIndices: true });
db.public.registerFunction({
name: 'version',
returns: 'text' as any,
implementation: () => 'pg-mem',
});
db.public.registerFunction({
name: 'current_database',
returns: 'text' as any,
implementation: () => 'tripz',
});
db.registerExtension('uuid-ossp', (schema) =>
schema.registerFunction({
name: 'uuid_generate_v4',
returns: 'uuid' as any,
implementation: () => randomUUID(),
impure: true,
}),
);
await db.public.none(`CREATE EXTENSION "uuid-ossp"`);
ds = (await db.adapters.createTypeormDataSource({
type: 'postgres',
entities: [Wallet, WalletTxn],
entityPrefix: 'tripz_',
})) as DataSource;
await ds.initialize();
await ds.synchronize();
// شبكة الأمان التي تضيفها الهجرة WalletLedger
await ds.query(
`ALTER TABLE tripz_wallets ADD CONSTRAINT tripz_wallets_balance_non_negative CHECK (balance >= 0)`,
);
wallet = new WalletService(ds.getRepository(Wallet), ds.getRepository(WalletTxn));
});
afterEach(async () => {
if (ds?.isInitialized) await ds.destroy();
});
it('ينشئ المحفظة عند أول إيداع ويضبط الرصيد', async () => {
const w = await wallet.credit(TENANT, USER, 10, 'topup');
expect(w.balance).toBe(10);
});
it('يجمع الإيداعات المتتالية بلا فقدان', async () => {
await wallet.credit(TENANT, USER, 10, 'topup');
await wallet.credit(TENANT, USER, 5.5, 'topup');
const w = await wallet.getOrCreate(TENANT, USER);
expect(Number(w.balance)).toBe(15.5);
});
it('يخصم عند توفّر الرصيد', async () => {
await wallet.credit(TENANT, USER, 20, 'topup');
const w = await wallet.debit(TENANT, USER, 8, 'trip_fare');
expect(w.balance).toBe(12);
});
it('يرفض الخصم عند نقص الرصيد ولا يغيّر شيئاً', async () => {
await wallet.credit(TENANT, USER, 5, 'topup');
await expect(wallet.debit(TENANT, USER, 9, 'trip_fare')).rejects.toThrow(BadRequestException);
const w = await wallet.getOrCreate(TENANT, USER);
expect(Number(w.balance)).toBe(5);
// القيد المرفوض لا يُسجَّل في الدفتر
const txns = await wallet.history(TENANT, w.id);
expect(txns.filter((t) => t.type === 'debit')).toHaveLength(0);
});
it('يرفض الخصم من محفظة غير موجودة', async () => {
await expect(wallet.debit(TENANT, USER, 1, 'trip_fare')).rejects.toThrow(BadRequestException);
});
it('يرفض المبالغ غير الصالحة', async () => {
for (const bad of [0, -5, NaN, Infinity]) {
await expect(wallet.credit(TENANT, USER, bad, 'topup')).rejects.toThrow(BadRequestException);
await expect(wallet.debit(TENANT, USER, bad, 'payout')).rejects.toThrow(BadRequestException);
}
});
it('الدفتر يطابق الرصيد ويسجّل balance_after', async () => {
await wallet.credit(TENANT, USER, 30, 'topup');
await wallet.debit(TENANT, USER, 12, 'trip_fare');
await wallet.credit(TENANT, USER, 2, 'refund');
const w = await wallet.getOrCreate(TENANT, USER);
const txns = await wallet.history(TENANT, w.id);
expect(txns).toHaveLength(3);
const sum = txns.reduce(
(acc, t) => acc + (t.type === 'credit' ? 1 : -1) * Number(t.amount),
0,
);
expect(sum).toBe(Number(w.balance));
expect(sum).toBe(20);
const last = txns.find((t) => t.reason === 'refund')!;
expect(Number(last.balance_after)).toBe(20);
});
it('محافظ مستأجرين مختلفين معزولة رغم نفس user_id', async () => {
const other = '33333333-3333-3333-3333-333333333333';
await wallet.credit(TENANT, USER, 10, 'topup');
await wallet.credit(other, USER, 7, 'topup');
expect(Number((await wallet.getOrCreate(TENANT, USER)).balance)).toBe(10);
expect(Number((await wallet.getOrCreate(other, USER)).balance)).toBe(7);
});
});
+86 -31
View File
@@ -1,9 +1,20 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { EntityManager, Repository } from 'typeorm';
import { Wallet } from './entities/wallet.entity';
import { WalletTxn } from './entities/wallet-txn.entity';
type TxnType = 'credit' | 'debit';
/**
* المحفظة — دفتر قيود (wallet_txns) + رصيد مادّي (wallets.balance).
*
* قاعدة صارمة (docs/17 — I1): **لا يُقرأ الرصيد ثم يُكتب**. كل تغيير يمرّ بعبارة
* `UPDATE … SET balance = balance ± :delta` واحدة، والخصم مشروط بـ
* `balance >= :amount` داخل نفس العبارة. القاعدة تسلسل التحديثات على الصف،
* فخصمان متزامنان لا يكتبان فوق بعضهما.
* القيد والرصيد يُكتبان في **معاملة واحدة** — لا خصم بلا قيد ولا قيد بلا خصم.
*/
@Injectable()
export class WalletService {
constructor(
@@ -12,40 +23,16 @@ export class WalletService {
) {}
async getOrCreate(tenantId: string, userId: string, currency = 'JOD'): Promise<Wallet> {
let w = await this.wallets.findOne({ where: { tenant_id: tenantId, user_id: userId } });
if (!w) {
w = await this.wallets.save(
this.wallets.create({ tenant_id: tenantId, user_id: userId, balance: 0, currency }),
);
}
return w;
await this.ensureWallet(this.wallets.manager, tenantId, userId, currency);
return (await this.wallets.findOne({ where: { tenant_id: tenantId, user_id: userId } }))!;
}
private num(v: number | string): number {
return typeof v === 'string' ? Number(v) : v;
credit(tenantId: string, userId: string, amount: number, reason: string, ref?: string) {
return this.apply(tenantId, userId, amount, 'credit', reason, ref);
}
async credit(tenantId: string, userId: string, amount: number, reason: string, ref?: string) {
if (amount <= 0) throw new BadRequestException('amount must be > 0');
const w = await this.getOrCreate(tenantId, userId);
w.balance = Number((this.num(w.balance) + amount).toFixed(3));
await this.wallets.save(w);
await this.txns.save(
this.txns.create({ tenant_id: tenantId, wallet_id: w.id, amount, type: 'credit', reason, ref }),
);
return w;
}
async debit(tenantId: string, userId: string, amount: number, reason: string, ref?: string) {
if (amount <= 0) throw new BadRequestException('amount must be > 0');
const w = await this.getOrCreate(tenantId, userId);
if (this.num(w.balance) < amount) throw new BadRequestException('Insufficient balance');
w.balance = Number((this.num(w.balance) - amount).toFixed(3));
await this.wallets.save(w);
await this.txns.save(
this.txns.create({ tenant_id: tenantId, wallet_id: w.id, amount, type: 'debit', reason, ref }),
);
return w;
debit(tenantId: string, userId: string, amount: number, reason: string, ref?: string) {
return this.apply(tenantId, userId, amount, 'debit', reason, ref);
}
history(tenantId: string, walletId: string) {
@@ -55,4 +42,72 @@ export class WalletService {
take: 100,
});
}
// ---- داخلي ----
/** إنشاء المحفظة إن غابت. ON CONFLICT DO NOTHING — إنشاءان متزامنان لا يتصادمان. */
private async ensureWallet(
em: EntityManager,
tenantId: string,
userId: string,
currency = 'JOD',
): Promise<void> {
await em
.createQueryBuilder()
.insert()
.into(Wallet)
.values({ tenant_id: tenantId, user_id: userId, balance: 0, currency })
.orIgnore()
.execute();
}
private async apply(
tenantId: string,
userId: string,
amount: number,
type: TxnType,
reason: string,
ref?: string,
): Promise<Wallet> {
if (!Number.isFinite(amount) || amount <= 0) {
throw new BadRequestException('amount must be > 0');
}
const delta = type === 'credit' ? amount : -amount;
return this.wallets.manager.transaction(async (em) => {
await this.ensureWallet(em, tenantId, userId);
const qb = em
.createQueryBuilder()
.update(Wallet)
.set({ balance: () => 'balance + :delta' })
.where('tenant_id = :tenantId AND user_id = :userId')
.setParameters({ delta, tenantId, userId });
// شرط الرصيد داخل نفس العبارة: لا فجوة بين الفحص والخصم.
if (type === 'debit') qb.andWhere('balance >= :amount', { amount });
const res = await qb.returning('*').execute();
if (!res.raw?.length) throw new BadRequestException('Insufficient balance');
const wallet = this.hydrate(res.raw[0]);
await em.getRepository(WalletTxn).insert({
tenant_id: tenantId,
wallet_id: wallet.id,
amount,
type,
reason,
ref,
balance_after: wallet.balance,
});
return wallet;
});
}
/** Postgres يرجّع numeric كنص — نوحّده رقماً كما تتوقّعه بقية الخدمات. */
private hydrate(row: any): Wallet {
return { ...row, balance: Number(row.balance) } as Wallet;
}
}