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:
co-authored by
Claude Opus 4.8
parent
b7e91e5de6
commit
9d6b752ea8
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user