fix: seed starting tariffs on tenant provision, not for siro only

ثغرة كُشفت على السيرفر: `SeedService` يزرع التعرفة لـ«سيرو» وحده، فمستأجرا
`intaleq` و`yalla` وُلدا بلا تسعير وبقيا كذلك حتى شُغّل `tariffs.sql` يدوياً.

الأثر صامت لا ظاهر: بلا تعرفة فعّالة لا يُقفل سعر للراكب، ثم تأخذ
`settleFare` القيمة `quoted_fare ?? 0` — رحلة مجانية وعمولة صفر.

`provision()` صار يزرع كل فئات الخدمة بعملة البلد ومنطقته الزمنية. مستودع
التعرفة محقون مباشرةً لا عبر `TariffService`: تلك الوحدة تستورد وحدة
المستأجرين، فالاستيراد المتبادل دورة.

وسطر لوغ الزرع كان يطبع 9 دائماً (ثابتاً لا عدّاداً) فلا يميّز «زُرعت» من
«كانت موجودة» — وهو الفرق الوحيد الذي يُقرأ منه.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hamza-Ayed
2026-07-19 00:45:26 +03:00
co-authored by Claude Opus 4.8
parent 90c3f71430
commit 900c78bb97
4 changed files with 143 additions and 4 deletions
+13 -2
View File
@@ -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[] = [
@@ -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);
});
});
@@ -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],
+44 -1
View File
@@ -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<Tenant>,
@@ -28,6 +36,10 @@ export class TenantsService {
private readonly trips: Repository<Trip>,
@InjectRepository(TenantRevenueEntry)
private readonly revenueLedger: Repository<TenantRevenueEntry>,
// مستودع التعرفة مباشرةً لا `TariffService`: `TariffModule` يستورد هذه
// الوحدة، فحقن الخدمة كان يصنع دورة. والمطلوب هنا إدراج صفوف لا أكثر.
@InjectRepository(Tariff)
private readonly tariffs: Repository<Tariff>,
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<void> {
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<string, any>): Promise<Tenant> {
const t = await this.repo.findOne({ where: { id: tenantId } });