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'` لأنها القيمة
|
// البلد من `country_pack` للمستأجر، والمدينة `'default'` لأنها القيمة
|
||||||
// التي يبحث بها `trips.service` فعلاً.
|
// التي يبحث بها `trips.service` فعلاً.
|
||||||
const country = countryTariff(tenant.countryPack);
|
const country = countryTariff(tenant.countryPack);
|
||||||
|
let created = 0;
|
||||||
|
let skipped = 0;
|
||||||
for (const serviceClass of Object.values(SERVICE_CLASSES)) {
|
for (const serviceClass of Object.values(SERVICE_CLASSES)) {
|
||||||
const existing = await this.tariff.getActive(tenant.id, 'default', serviceClass);
|
const existing = await this.tariff.getActive(tenant.id, 'default', serviceClass);
|
||||||
if (existing) continue;
|
if (existing) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
created++;
|
||||||
await this.tariff.create({
|
await this.tariff.create({
|
||||||
tenant_id: tenant.id,
|
tenant_id: tenant.id,
|
||||||
city: 'default',
|
city: 'default',
|
||||||
@@ -64,7 +70,12 @@ export class SeedService implements OnModuleInit {
|
|||||||
definition: buildDefinition(country, serviceClass),
|
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[] = [
|
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 { Tenant } from '../../database/entities/tenant.entity';
|
||||||
import { Trip } from '../trips/entities/trip.entity';
|
import { Trip } from '../trips/entities/trip.entity';
|
||||||
import { TenantRevenueEntry } from '../tenant-wallet/entities/tenant-ledger.entity';
|
import { TenantRevenueEntry } from '../tenant-wallet/entities/tenant-ledger.entity';
|
||||||
|
import { Tariff } from '../tariff/entities/tariff.entity';
|
||||||
import { TenantsService } from './tenants.service';
|
import { TenantsService } from './tenants.service';
|
||||||
import { TenantsController } from './tenants.controller';
|
import { TenantsController } from './tenants.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
// Trip ودفتر الإيراد هنا **للقراءة فقط** — تقرير GMV والإيراد للسوبر-أدمن
|
// Trip ودفتر الإيراد هنا **للقراءة فقط** — تقرير GMV والإيراد للسوبر-أدمن
|
||||||
// (docs/22 — N1، docs/24 — P5). الكتابة في الدفتر تخصّ TenantWalletService وحده.
|
// (docs/22 — N1، docs/24 — P5). الكتابة في الدفتر تخصّ TenantWalletService وحده.
|
||||||
imports: [TypeOrmModule.forFeature([Tenant, Trip, TenantRevenueEntry])],
|
// `Tariff` هنا لزرع تعرفة الانطلاق عند التزويد. المستودع مباشرةً لا
|
||||||
|
// `TariffModule` — تلك الوحدة تستورد هذه، فالاستيراد المتبادل دورة.
|
||||||
|
imports: [TypeOrmModule.forFeature([Tenant, Trip, TenantRevenueEntry, Tariff])],
|
||||||
controllers: [TenantsController],
|
controllers: [TenantsController],
|
||||||
providers: [TenantsService],
|
providers: [TenantsService],
|
||||||
exports: [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 { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { Tenant, TenantPlan } from '../../database/entities/tenant.entity';
|
import { Tenant, TenantPlan } from '../../database/entities/tenant.entity';
|
||||||
import { Trip } from '../trips/entities/trip.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 { TenantRevenueEntry } from '../tenant-wallet/entities/tenant-ledger.entity';
|
||||||
import { CacheService, CacheKeys, TTL } from '../../common/cache/cache.service';
|
import { CacheService, CacheKeys, TTL } from '../../common/cache/cache.service';
|
||||||
import { EntitlementsService } from '../../common/entitlements/entitlements.service';
|
import { EntitlementsService } from '../../common/entitlements/entitlements.service';
|
||||||
@@ -21,6 +27,8 @@ export interface ProvisionDto {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TenantsService {
|
export class TenantsService {
|
||||||
|
private readonly logger = new Logger('Tenants');
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(Tenant)
|
@InjectRepository(Tenant)
|
||||||
private readonly repo: Repository<Tenant>,
|
private readonly repo: Repository<Tenant>,
|
||||||
@@ -28,6 +36,10 @@ export class TenantsService {
|
|||||||
private readonly trips: Repository<Trip>,
|
private readonly trips: Repository<Trip>,
|
||||||
@InjectRepository(TenantRevenueEntry)
|
@InjectRepository(TenantRevenueEntry)
|
||||||
private readonly revenueLedger: Repository<TenantRevenueEntry>,
|
private readonly revenueLedger: Repository<TenantRevenueEntry>,
|
||||||
|
// مستودع التعرفة مباشرةً لا `TariffService`: `TariffModule` يستورد هذه
|
||||||
|
// الوحدة، فحقن الخدمة كان يصنع دورة. والمطلوب هنا إدراج صفوف لا أكثر.
|
||||||
|
@InjectRepository(Tariff)
|
||||||
|
private readonly tariffs: Repository<Tariff>,
|
||||||
private readonly cache: CacheService,
|
private readonly cache: CacheService,
|
||||||
private readonly entitlements: EntitlementsService,
|
private readonly entitlements: EntitlementsService,
|
||||||
) {}
|
) {}
|
||||||
@@ -119,10 +131,41 @@ export class TenantsService {
|
|||||||
status: 'active',
|
status: 'active',
|
||||||
});
|
});
|
||||||
const saved = await this.repo.save(tenant);
|
const saved = await this.repo.save(tenant);
|
||||||
|
await this.seedTariffs(saved);
|
||||||
await this.invalidate(saved);
|
await this.invalidate(saved);
|
||||||
return 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 · ألوان · مفتاح اللوغو. */
|
/** الهوية البصرية (docs/22 — N2): app_name · bundle ids · ألوان · مفتاح اللوغو. */
|
||||||
async setBranding(tenantId: string, branding: Record<string, any>): Promise<Tenant> {
|
async setBranding(tenantId: string, branding: Record<string, any>): Promise<Tenant> {
|
||||||
const t = await this.repo.findOne({ where: { id: tenantId } });
|
const t = await this.repo.findOne({ where: { id: tenantId } });
|
||||||
|
|||||||
Reference in New Issue
Block a user