From 900c78bb97a1faff51536e906afc1470c4a5e9d7 Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Sun, 19 Jul 2026 00:45:26 +0300 Subject: [PATCH] fix: seed starting tariffs on tenant provision, not for siro only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ثغرة كُشفت على السيرفر: `SeedService` يزرع التعرفة لـ«سيرو» وحده، فمستأجرا `intaleq` و`yalla` وُلدا بلا تسعير وبقيا كذلك حتى شُغّل `tariffs.sql` يدوياً. الأثر صامت لا ظاهر: بلا تعرفة فعّالة لا يُقفل سعر للراكب، ثم تأخذ `settleFare` القيمة `quoted_fare ?? 0` — رحلة مجانية وعمولة صفر. `provision()` صار يزرع كل فئات الخدمة بعملة البلد ومنطقته الزمنية. مستودع التعرفة محقون مباشرةً لا عبر `TariffService`: تلك الوحدة تستورد وحدة المستأجرين، فالاستيراد المتبادل دورة. وسطر لوغ الزرع كان يطبع 9 دائماً (ثابتاً لا عدّاداً) فلا يميّز «زُرعت» من «كانت موجودة» — وهو الفرق الوحيد الذي يُقرأ منه. Co-Authored-By: Claude Opus 4.8 --- backend/src/common/seed/seed.service.ts | 15 +++- .../modules/tenants/provision-tariffs.spec.ts | 82 +++++++++++++++++++ backend/src/modules/tenants/tenants.module.ts | 5 +- .../src/modules/tenants/tenants.service.ts | 45 +++++++++- 4 files changed, 143 insertions(+), 4 deletions(-) create mode 100644 backend/src/modules/tenants/provision-tariffs.spec.ts diff --git a/backend/src/common/seed/seed.service.ts b/backend/src/common/seed/seed.service.ts index d1bcc6a..5b67ce7 100644 --- a/backend/src/common/seed/seed.service.ts +++ b/backend/src/common/seed/seed.service.ts @@ -52,9 +52,15 @@ export class SeedService implements OnModuleInit { // البلد من `country_pack` للمستأجر، والمدينة `'default'` لأنها القيمة // التي يبحث بها `trips.service` فعلاً. const country = countryTariff(tenant.countryPack); + let created = 0; + let skipped = 0; for (const serviceClass of Object.values(SERVICE_CLASSES)) { const existing = await this.tariff.getActive(tenant.id, 'default', serviceClass); - if (existing) continue; + if (existing) { + skipped++; + continue; + } + created++; await this.tariff.create({ tenant_id: tenant.id, city: 'default', @@ -64,7 +70,12 @@ export class SeedService implements OnModuleInit { definition: buildDefinition(country, serviceClass), }); } - this.logger.log(`seeded ${Object.keys(SERVICE_CLASSES).length} tariffs for "${tenant.slug}" (${country.currency})`); + // العدد الحقيقي لا ثابت: الرسالة السابقة طبعت 9 دائماً حتى حين تُتخطّى + // كل الفئات، فلم تكن تُميّز «زُرعت» من «كانت موجودة» — وهو الفرق الوحيد + // الذي يُقرأ من هذا السطر أصلاً. + this.logger.log( + `tariffs for "${tenant.slug}" (${country.currency}): ${created} seeded, ${skipped} already active`, + ); // أنواع الرحلات الافتراضية const types: any[] = [ diff --git a/backend/src/modules/tenants/provision-tariffs.spec.ts b/backend/src/modules/tenants/provision-tariffs.spec.ts new file mode 100644 index 0000000..5d94f9b --- /dev/null +++ b/backend/src/modules/tenants/provision-tariffs.spec.ts @@ -0,0 +1,82 @@ +import { TenantsService } from './tenants.service'; +import { SERVICE_CLASSES } from '../tariff/default-tariffs'; + +/** + * حارس ثغرة كُشفت على السيرفر (2026-07-19): `SeedService` كان يزرع التعرفة + * لـ«سيرو» وحده، فمستأجرا `intaleq` و`yalla` وُلدا **بلا تسعير** وبقيا كذلك + * حتى شُغّل `tariffs.sql` يدوياً. + * + * الأثر لم يكن خطأً ظاهراً بل صمتاً: بلا تعرفة فعّالة لا يُقفل سعر، ثم تأخذ + * `settleFare` القيمة `quoted_fare ?? 0` — أي **رحلة مجانية وعمولة صفر**. + */ +describe('TenantsService.provision — تعرفة الانطلاق', () => { + function build(existingSlug: string | null = null) { + const savedTariffs: any[] = []; + const tenantRepo = { + findOne: jest.fn().mockResolvedValue(existingSlug ? { slug: existingSlug } : null), + create: jest.fn((x: any) => x), + save: jest.fn(async (x: any) => ({ ...x, id: 'tenant-uuid' })), + } as any; + const tariffRepo = { + create: jest.fn((x: any) => x), + save: jest.fn(async (rows: any[]) => { + savedTariffs.push(...rows); + return rows; + }), + } as any; + const svc = new TenantsService( + tenantRepo, + {} as any, // trips (قراءة تقارير فقط) + {} as any, // revenueLedger + tariffRepo, + { del: jest.fn() } as any, // cache + { invalidate: jest.fn() } as any, // entitlements + ); + return { svc, savedTariffs, tariffRepo }; + } + + it('**كل فئة خدمة تُزرع** لمستأجر جديد — لا الاقتصادي وحده', async () => { + const { svc, savedTariffs } = build(); + await svc.provision({ name: 'Test', slug: 'test-jo', countryPack: 'jo' }); + + const classes = savedTariffs.map((t) => t.service_class).sort(); + expect(classes).toEqual(Object.values(SERVICE_CLASSES).sort()); + }); + + it('يزرع بعملة البلد ومنطقته الزمنية — لا افتراض الأردن للجميع', async () => { + const { svc, savedTariffs } = build(); + await svc.provision({ name: 'Masr', slug: 'test-eg', countryPack: 'eg' }); + + const economy = savedTariffs.find((t) => t.service_class === 'economy'); + expect(economy.definition.currency).toBe('EGP'); + expect(economy.definition.timezone).toBe('Africa/Cairo'); + expect(economy.definition.min_fare).toBe(20); + }); + + it('يزرع على `city = "default"` — القيمة التي يبحث بها مسار الرحلة فعلاً', async () => { + const { svc, savedTariffs } = build(); + await svc.provision({ name: 'Sham', slug: 'test-sy', countryPack: 'sy' }); + + // `trips.service`: `dto.city ?? 'default'` — أي قيمة أخرى لا يجدها أحد. + for (const t of savedTariffs) expect(t.city).toBe('default'); + expect(savedTariffs[0].active).toBe(true); + expect(savedTariffs[0].version).toBe(1); + }); + + it('فشل زرع التعرفة لا يُسقط التزويد — المستأجر أُنشئ فعلاً', async () => { + const { svc, tariffRepo } = build(); + tariffRepo.save.mockRejectedValue(new Error('db down')); + // الصف حُفظ قبل الزرع؛ الرمي هنا كان سيترك مستأجراً موجوداً واستجابةً فاشلة. + await expect( + svc.provision({ name: 'X', slug: 'test-x', countryPack: 'jo' }), + ).resolves.toMatchObject({ slug: 'test-x' }); + }); + + it('slug مكرر يُرفض قبل أي زرع', async () => { + const { svc, savedTariffs } = build('taken'); + await expect( + svc.provision({ name: 'Dup', slug: 'taken', countryPack: 'jo' }), + ).rejects.toThrow(/slug already taken/); + expect(savedTariffs).toHaveLength(0); + }); +}); diff --git a/backend/src/modules/tenants/tenants.module.ts b/backend/src/modules/tenants/tenants.module.ts index 9fd18fb..4a90e82 100644 --- a/backend/src/modules/tenants/tenants.module.ts +++ b/backend/src/modules/tenants/tenants.module.ts @@ -3,13 +3,16 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { Tenant } from '../../database/entities/tenant.entity'; import { Trip } from '../trips/entities/trip.entity'; import { TenantRevenueEntry } from '../tenant-wallet/entities/tenant-ledger.entity'; +import { Tariff } from '../tariff/entities/tariff.entity'; import { TenantsService } from './tenants.service'; import { TenantsController } from './tenants.controller'; @Module({ // Trip ودفتر الإيراد هنا **للقراءة فقط** — تقرير GMV والإيراد للسوبر-أدمن // (docs/22 — N1، docs/24 — P5). الكتابة في الدفتر تخصّ TenantWalletService وحده. - imports: [TypeOrmModule.forFeature([Tenant, Trip, TenantRevenueEntry])], + // `Tariff` هنا لزرع تعرفة الانطلاق عند التزويد. المستودع مباشرةً لا + // `TariffModule` — تلك الوحدة تستورد هذه، فالاستيراد المتبادل دورة. + imports: [TypeOrmModule.forFeature([Tenant, Trip, TenantRevenueEntry, Tariff])], controllers: [TenantsController], providers: [TenantsService], exports: [TenantsService], diff --git a/backend/src/modules/tenants/tenants.service.ts b/backend/src/modules/tenants/tenants.service.ts index 67dd368..feeaf9f 100644 --- a/backend/src/modules/tenants/tenants.service.ts +++ b/backend/src/modules/tenants/tenants.service.ts @@ -1,8 +1,14 @@ -import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Tenant, TenantPlan } from '../../database/entities/tenant.entity'; import { Trip } from '../trips/entities/trip.entity'; +import { Tariff } from '../tariff/entities/tariff.entity'; +import { + buildDefinition, + countryTariff, + SERVICE_CLASSES, +} from '../tariff/default-tariffs'; import { TenantRevenueEntry } from '../tenant-wallet/entities/tenant-ledger.entity'; import { CacheService, CacheKeys, TTL } from '../../common/cache/cache.service'; import { EntitlementsService } from '../../common/entitlements/entitlements.service'; @@ -21,6 +27,8 @@ export interface ProvisionDto { @Injectable() export class TenantsService { + private readonly logger = new Logger('Tenants'); + constructor( @InjectRepository(Tenant) private readonly repo: Repository, @@ -28,6 +36,10 @@ export class TenantsService { private readonly trips: Repository, @InjectRepository(TenantRevenueEntry) private readonly revenueLedger: Repository, + // مستودع التعرفة مباشرةً لا `TariffService`: `TariffModule` يستورد هذه + // الوحدة، فحقن الخدمة كان يصنع دورة. والمطلوب هنا إدراج صفوف لا أكثر. + @InjectRepository(Tariff) + private readonly tariffs: Repository, private readonly cache: CacheService, private readonly entitlements: EntitlementsService, ) {} @@ -119,10 +131,41 @@ export class TenantsService { status: 'active', }); const saved = await this.repo.save(tenant); + await this.seedTariffs(saved); await this.invalidate(saved); return saved; } + /** + * تعرفة الانطلاق لمستأجر جديد — **كل فئات الخدمة** بعملة بلده. + * + * كان `SeedService` يزرع لـ«سيرو» وحده، فأي مستأجر يُنشأ عبر هذه النقطة + * يولد **بلا تسعير**: `getActive` لا تجد شيئاً ← الرحلة بلا سعر مقفول ← + * `settleFare` تأخذ `quoted_fare ?? 0` ← رحلة مجانية وعمولة صفر. كُشفت + * على السيرفر: مستأجرا `intaleq` و`yalla` كانا بلا تعرفة حتى شُغّل + * `tariffs.sql` يدوياً — وهو ما لا يجوز أن يكون شرطاً لعمل مستأجر. + * + * لا يرمي: مستأجر بلا تعرفة أهون من تزويد يفشل بعد إنشاء الصف. + */ + private async seedTariffs(tenant: Tenant): Promise { + try { + const country = countryTariff(tenant.countryPack); + const rows = Object.values(SERVICE_CLASSES).map((serviceClass) => + this.tariffs.create({ + tenant_id: tenant.id, + city: 'default', + service_class: serviceClass, + version: 1, + active: true, + definition: buildDefinition(country, serviceClass), + }), + ); + await this.tariffs.save(rows); + } catch (e: any) { + this.logger.error(`tariff seed failed for ${tenant.slug}: ${e?.message}`); + } + } + /** الهوية البصرية (docs/22 — N2): app_name · bundle ids · ألوان · مفتاح اللوغو. */ async setBranding(tenantId: string, branding: Record): Promise { const t = await this.repo.findOne({ where: { id: tenantId } });