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:
co-authored by
Claude Opus 4.8
parent
90c3f71430
commit
900c78bb97
@@ -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],
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
Reference in New Issue
Block a user