diff --git a/backend/src/database/migrations/1721840000000-TripPricingAndTimestamps.ts b/backend/src/database/migrations/1721840000000-TripPricingAndTimestamps.ts new file mode 100644 index 0000000..e278455 --- /dev/null +++ b/backend/src/database/migrations/1721840000000-TripPricingAndTimestamps.ts @@ -0,0 +1,52 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * نموذج الرحلة (docs/17 — B2/B3/B6/B7): + * - فصل السعر: ما يدفعه الراكب مقابل ما يقبضه السائق + العمولة. + * - الانتظار: الدقائق ورسمها. + * - مشوار وصول السائق للراكب: مسافته وزمنه ورسمه. + * - طوابع دقيقة: انطلاق نحو الراكب · الوصول · بدء الرحلة. + */ +export class TripPricingAndTimestamps1721840000000 implements MigrationInterface { + public async up(q: QueryRunner): Promise { + const add = (col: string, type: string) => + q.query(`ALTER TABLE tripz_trips ADD COLUMN IF NOT EXISTS ${col} ${type}`); + + await add('price_for_passenger', 'numeric(12,3)'); + await add('price_for_driver', 'numeric(12,3)'); + await add('commission_amount', 'numeric(12,3)'); + await add('commission_rate', 'numeric(5,2)'); + + await add('waiting_min', 'numeric(6,1)'); + await add('waiting_charge', 'numeric(12,3)'); + + await add('pickup_distance_km', 'numeric(8,3)'); + await add('pickup_duration_min', 'numeric(8,1)'); + await add('pickup_charge', 'numeric(12,3)'); + + await add('driver_going_at', 'timestamptz'); + await add('arrived_at', 'timestamptz'); + await add('started_at', 'timestamptz'); + + // رحلات قائمة: الراكب دفع = السائق قبض (لم تكن هناك عمولة أصلاً). + await q.query(` + UPDATE tripz_trips + SET price_for_passenger = COALESCE(price_for_passenger, final_fare, quoted_fare), + price_for_driver = COALESCE(price_for_driver, final_fare, quoted_fare) + WHERE quoted_fare IS NOT NULL OR final_fare IS NOT NULL + `); + } + + public async down(q: QueryRunner): Promise { + const drop = (col: string) => + q.query(`ALTER TABLE tripz_trips DROP COLUMN IF EXISTS ${col}`); + for (const c of [ + 'started_at', 'arrived_at', 'driver_going_at', + 'pickup_charge', 'pickup_duration_min', 'pickup_distance_km', + 'waiting_charge', 'waiting_min', + 'commission_rate', 'commission_amount', 'price_for_driver', 'price_for_passenger', + ]) { + await drop(c); + } + } +} diff --git a/backend/src/modules/tariff/entities/tariff.entity.ts b/backend/src/modules/tariff/entities/tariff.entity.ts index 485664e..544d14b 100644 --- a/backend/src/modules/tariff/entities/tariff.entity.ts +++ b/backend/src/modules/tariff/entities/tariff.entity.ts @@ -58,4 +58,19 @@ export interface TariffDefinition { booking_fee?: number; min_fare?: number; surge?: { enabled: boolean; multiplier?: number; max?: number }; + + /** دقائق انتظار مجانية بعد وصول السائق قبل بدء العدّ (docs/17 — B2). القانون: 5. */ + free_waiting_min?: number; + + /** + * فصل السعر (docs/17 — B6): ما يدفعه الراكب مقابل ما يقبضه السائق. + * العمولة = percent% من الأجرة + flat، وبحدّ أدنى min. غيابها = بلا عمولة. + */ + commission?: { percent?: number; flat?: number; min?: number }; + + /** + * مشوار وصول السائق للراكب (docs/17 — B3). `charge: false` افتراضاً — + * تفعيله يرفع الأجرة على كل رحلة، فهو قرار مالك لا افتراض صامت. + */ + pickup_leg?: { charge: boolean }; } diff --git a/backend/src/modules/tariff/tariff.engine.spec.ts b/backend/src/modules/tariff/tariff.engine.spec.ts new file mode 100644 index 0000000..517d13e --- /dev/null +++ b/backend/src/modules/tariff/tariff.engine.spec.ts @@ -0,0 +1,101 @@ +import { TariffEngine } from './tariff.engine'; +import { TariffDefinition } from './entities/tariff.entity'; + +const DAY = new Date('2026-07-17T09:00:00Z'); // داخل نافذة النهار + +function def(extra: Partial = {}): TariffDefinition { + return { + currency: 'JOD', + mode: 'time_and_distance', + windows: [ + { name: 'day', from: '06:00', to: '22:00', flag: 0.39, per_km: 0.28, per_min: 0.06, per_min_waiting: 0.5 }, + { name: 'night', from: '22:00', to: '06:00', flag: 0.4, per_km: 0.33, per_min: 0.07, per_min_waiting: 1.0 }, + ], + ...extra, + }; +} + +describe('TariffEngine.waitingCharge — الانتظار (B2)', () => { + it('أول 5 دقائق مجانية بحكم القانون', () => { + const r = TariffEngine.waitingCharge(def(), 5, DAY); + expect(r.freeMin).toBe(5); + expect(r.billableMin).toBe(0); + expect(r.charge).toBe(0); + }); + + it('ما زاد عن المجاني يُحسب بسعر انتظار النافذة', () => { + const r = TariffEngine.waitingCharge(def(), 9, DAY); + expect(r.billableMin).toBe(4); + expect(r.charge).toBeCloseTo(4 * 0.5); + }); + + it('انتظار أقل من المجاني لا يُنتج رصيداً سالباً', () => { + expect(TariffEngine.waitingCharge(def(), 2, DAY).charge).toBe(0); + }); + + it('الدقائق المجانية قابلة للضبط لكل تعرفة', () => { + const r = TariffEngine.waitingCharge(def({ free_waiting_min: 10 }), 9, DAY); + expect(r.billableMin).toBe(0); + }); + + it('نافذة الليل أغلى', () => { + const night = new Date('2026-07-17T23:00:00Z'); + expect(TariffEngine.waitingCharge(def(), 9, night).charge).toBeCloseTo(4 * 1.0); + }); +}); + +describe('TariffEngine.split — فصل السعر (B6)', () => { + it('بلا عمولة: السائق يقبض كل ما دفعه الراكب', () => { + const s = TariffEngine.split(def(), 10); + expect(s.forPassenger).toBe(10); + expect(s.forDriver).toBe(10); + expect(s.commission).toBe(0); + }); + + it('عمولة نسبية', () => { + const s = TariffEngine.split(def({ commission: { percent: 15 } }), 10); + expect(s.commission).toBeCloseTo(1.5); + expect(s.forDriver).toBeCloseTo(8.5); + expect(s.rate).toBe(15); + }); + + it('نسبة + مقطوع', () => { + const s = TariffEngine.split(def({ commission: { percent: 10, flat: 0.25 } }), 10); + expect(s.commission).toBeCloseTo(1.25); + expect(s.forDriver).toBeCloseTo(8.75); + }); + + it('حدّ أدنى للعمولة', () => { + const s = TariffEngine.split(def({ commission: { percent: 10, min: 0.5 } }), 2); + expect(s.commission).toBeCloseTo(0.5); // 0.2 أقل من الحد + expect(s.forDriver).toBeCloseTo(1.5); + }); + + it('العمولة لا تتجاوز الأجرة — دخل السائق لا يصير سالباً', () => { + const s = TariffEngine.split(def({ commission: { percent: 50, min: 5 } }), 2); + expect(s.commission).toBe(2); + expect(s.forDriver).toBe(0); + }); + + it('الراكب + السائق يتوازنان دائماً', () => { + for (const total of [0, 1.337, 10, 99.999]) { + const s = TariffEngine.split(def({ commission: { percent: 15 } }), total); + expect(s.forDriver + s.commission).toBeCloseTo(s.forPassenger, 3); + } + }); +}); + +describe('TariffEngine.pickupCharge — مشوار الوصول (B3)', () => { + it('لا يُحتسب ما لم يُفعَّل صراحةً', () => { + expect(TariffEngine.pickupCharge(def(), 5, 10, DAY)).toBe(0); + }); + + it('عند تفعيله يُحسب بمسافة النافذة وزمنها', () => { + const charge = TariffEngine.pickupCharge(def({ pickup_leg: { charge: true } }), 5, 10, DAY); + expect(charge).toBeCloseTo(5 * 0.28 + 10 * 0.06); + }); + + it('لا رسم على مشوار صفري', () => { + expect(TariffEngine.pickupCharge(def({ pickup_leg: { charge: true } }), 0, 0, DAY)).toBe(0); + }); +}); diff --git a/backend/src/modules/tariff/tariff.engine.ts b/backend/src/modules/tariff/tariff.engine.ts index 4a6668b..3a8aae2 100644 --- a/backend/src/modules/tariff/tariff.engine.ts +++ b/backend/src/modules/tariff/tariff.engine.ts @@ -77,6 +77,69 @@ export class TariffEngine { }; } + /** + * رسم الانتظار بعد وصول السائق (docs/17 — B2). الدقائق المجانية (5 قانوناً) + * تُخصم أولاً، والباقي يُحسب بسعر انتظار النافذة الفعّالة. + */ + static waitingCharge( + def: TariffDefinition, + waitedMin: number, + at: Date = new Date(), + ): { freeMin: number; billableMin: number; charge: number } { + const win = TariffEngine.pickWindow(def.windows, at); + const freeMin = def.free_waiting_min ?? 5; + const billableMin = Math.max(0, (waitedMin ?? 0) - freeMin); + return { + freeMin, + billableMin: TariffEngine.n(billableMin), + charge: TariffEngine.n(billableMin * (win.per_min_waiting ?? 0)), + }; + } + + /** + * رسم مشوار وصول السائق للراكب (docs/17 — B3). + * يرجع صفراً ما لم يُفعِّله المالك صراحةً في التعرفة. + */ + static pickupCharge( + def: TariffDefinition, + distanceKm: number, + durationMin: number, + at: Date = new Date(), + ): number { + if (!def.pickup_leg?.charge) return 0; + const win = TariffEngine.pickWindow(def.windows, at); + const charge = + Math.max(0, distanceKm ?? 0) * win.per_km + Math.max(0, durationMin ?? 0) * win.per_min; + return TariffEngine.n(charge); + } + + /** + * فصل السعر (docs/17 — B6): يقسم ما يدفعه الراكب إلى عمولة المنصة وحصة + * السائق. العمولة لا تتجاوز الأجرة مهما كانت الإعدادات — وإلا صار دخل + * السائق سالباً. + */ + static split( + def: TariffDefinition, + passengerTotal: number, + ): { forPassenger: number; forDriver: number; commission: number; rate: number } { + const total = Math.max(0, passengerTotal ?? 0); + const c = def.commission; + if (!c) { + return { forPassenger: TariffEngine.n(total), forDriver: TariffEngine.n(total), commission: 0, rate: 0 }; + } + const percent = c.percent ?? 0; + let commission = total * (percent / 100) + (c.flat ?? 0); + if (c.min != null && commission < c.min) commission = c.min; + if (commission > total) commission = total; + commission = TariffEngine.n(commission); + return { + forPassenger: TariffEngine.n(total), + forDriver: TariffEngine.n(total - commission), + commission, + rate: percent, + }; + } + private static pickWindow(windows: TariffWindow[], at: Date): TariffWindow { const mins = at.getUTCHours() * 60 + at.getUTCMinutes(); for (const w of windows) { diff --git a/backend/src/modules/trips/entities/trip.entity.ts b/backend/src/modules/trips/entities/trip.entity.ts index 7ed31f0..90cf38c 100644 --- a/backend/src/modules/trips/entities/trip.entity.ts +++ b/backend/src/modules/trips/entities/trip.entity.ts @@ -79,6 +79,36 @@ export class Trip { @Column({ type: 'numeric', precision: 12, scale: 3, nullable: true }) final_fare: number | null; + // ---- فصل السعر (docs/17 — B6): ما يدفعه الراكب ≠ ما يقبضه السائق ---- + @Column({ type: 'numeric', precision: 12, scale: 3, nullable: true }) + price_for_passenger: number | null; + + @Column({ type: 'numeric', precision: 12, scale: 3, nullable: true }) + price_for_driver: number | null; + + @Column({ type: 'numeric', precision: 12, scale: 3, nullable: true }) + commission_amount: number | null; + + @Column({ type: 'numeric', precision: 5, scale: 2, nullable: true }) + commission_rate: number | null; + + // ---- الانتظار (docs/17 — B2) ---- + @Column({ type: 'numeric', precision: 6, scale: 1, nullable: true }) + waiting_min: number | null; + + @Column({ type: 'numeric', precision: 12, scale: 3, nullable: true }) + waiting_charge: number | null; + + // ---- مشوار وصول السائق للراكب (docs/17 — B3) ---- + @Column({ type: 'numeric', precision: 8, scale: 3, nullable: true }) + pickup_distance_km: number | null; + + @Column({ type: 'numeric', precision: 8, scale: 1, nullable: true }) + pickup_duration_min: number | null; + + @Column({ type: 'numeric', precision: 12, scale: 3, nullable: true }) + pickup_charge: number | null; + @Column({ nullable: true }) currency: string; @@ -91,12 +121,26 @@ export class Trip { @Column({ type: 'numeric', precision: 12, scale: 3, nullable: true }) cancel_fee: number | null; + // ---- طوابع دقيقة (docs/17 — B7؛ مقابلها في سيرو بين قوسين) ---- @CreateDateColumn() requested_at: Date; @Column({ type: 'timestamptz', nullable: true }) assigned_at: Date | null; + /** السائق انطلق نحو الراكب (`DriverIsGoingToPassenger`). */ + @Column({ type: 'timestamptz', nullable: true }) + driver_going_at: Date | null; + + /** السائق وصل نقطة الالتقاط — بداية عدّ الانتظار. */ + @Column({ type: 'timestamptz', nullable: true }) + arrived_at: Date | null; + + /** الرحلة بدأت فعلاً — الراكب في السيارة (`rideTimeStart`). */ + @Column({ type: 'timestamptz', nullable: true }) + started_at: Date | null; + + /** الرحلة انتهت (`rideTimeFinish`). */ @Column({ type: 'timestamptz', nullable: true }) completed_at: Date | null; diff --git a/backend/src/modules/trips/trip-state.service.spec.ts b/backend/src/modules/trips/trip-state.service.spec.ts index 548a998..7a81266 100644 --- a/backend/src/modules/trips/trip-state.service.spec.ts +++ b/backend/src/modules/trips/trip-state.service.spec.ts @@ -24,6 +24,8 @@ function makeTrip(overrides: Partial = {}): Trip { currency: 'JOD', payment_method: 'cash', assigned_at: null, + arrived_at: null, + started_at: null, ...overrides, } as Trip; } @@ -49,6 +51,17 @@ describe('TripStateService', () => { expect(s!.origin_lat).toBeCloseTo(31.95); expect(s!.quoted_fare).toBe(3.75); expect(s!.assigned_at).toBeNull(); + expect(s!.city).toBe('amman'); + }); + + it('يحمل طوابع الانتظار والبدء — يُحتسب منها الرسم بلا قراءة قاعدة (B2/B7)', async () => { + const arrived = new Date('2026-07-17T09:00:00.000Z'); + const started = new Date('2026-07-17T09:07:00.000Z'); + await state.save(makeTrip({ status: 'in_progress', arrived_at: arrived, started_at: started })); + + const s = await state.get(TENANT, 'trip-1'); + expect(s!.arrived_at?.toISOString()).toBe(arrived.toISOString()); + expect(s!.started_at?.toISOString()).toBe(started.toISOString()); }); it('يرجع null لرحلة غير موجودة (فيرجع المُنادي للقاعدة)', async () => { diff --git a/backend/src/modules/trips/trip-state.service.ts b/backend/src/modules/trips/trip-state.service.ts index d8d8dd5..39010f9 100644 --- a/backend/src/modules/trips/trip-state.service.ts +++ b/backend/src/modules/trips/trip-state.service.ts @@ -19,7 +19,12 @@ export interface TripState { distance_km: number | null; quoted_fare: number | null; currency: string | null; + city: string; assigned_at: Date | null; + /** بداية عدّ الانتظار — يُحتسب منه رسم الانتظار عند البدء (docs/17 — B2). */ + arrived_at: Date | null; + /** بدء الرحلة فعلاً — منه يُقاس زمنها الحقيقي (docs/17 — B7). */ + started_at: Date | null; } /** نتيجة محاولة القبول الذرّي. */ @@ -66,7 +71,10 @@ export class TripStateService { distance_km: trip.distance_km == null ? '' : String(trip.distance_km), quoted_fare: trip.quoted_fare == null ? '' : String(trip.quoted_fare), currency: trip.currency ?? '', + city: trip.city ?? '', assigned_at: trip.assigned_at ? trip.assigned_at.toISOString() : '', + arrived_at: trip.arrived_at ? trip.arrived_at.toISOString() : '', + started_at: trip.started_at ? trip.started_at.toISOString() : '', }) .expire(k, ACTIVE_TTL_SEC) .exec(); @@ -89,7 +97,10 @@ export class TripStateService { distance_km: h.distance_km ? Number(h.distance_km) : null, quoted_fare: h.quoted_fare ? Number(h.quoted_fare) : null, currency: h.currency || null, + city: h.city || 'default', assigned_at: h.assigned_at ? new Date(h.assigned_at) : null, + arrived_at: h.arrived_at ? new Date(h.arrived_at) : null, + started_at: h.started_at ? new Date(h.started_at) : null, }; } diff --git a/backend/src/modules/trips/trips.service.ts b/backend/src/modules/trips/trips.service.ts index b971841..9c55f80 100644 --- a/backend/src/modules/trips/trips.service.ts +++ b/backend/src/modules/trips/trips.service.ts @@ -11,6 +11,7 @@ import { Trip, TripStatus } from './entities/trip.entity'; import { TripEvent } from './entities/trip-event.entity'; import { MapsService } from '../maps/maps.service'; import { TariffService } from '../tariff/tariff.service'; +import { TariffEngine } from '../tariff/tariff.engine'; import { MatchingService } from '../matching/matching.service'; import { DriversService } from '../drivers/drivers.service'; import { RealtimeGateway } from '../../realtime/realtime.gateway'; @@ -113,6 +114,10 @@ export class TripsService { let currency: string | null = null; let tariffId: string | null = null; let tariffVersion: number | null = null; + // فصل تقديري يُعرض على السائق ليعرف دخله قبل القبول (docs/17 — B6). + let priceForDriver: number | null = null; + let commissionAmount: number | null = null; + let commissionRate: number | null = null; try { const q = await this.tariff.quote(tenantId, city, serviceClass, { distanceKm: route.distanceKm, @@ -122,6 +127,14 @@ export class TripsService { currency = q.quote.currency; tariffId = q.tariffId; tariffVersion = q.version; + + const def = (await this.tariff.getActive(tenantId, city, serviceClass))?.definition; + if (def) { + const split = TariffEngine.split(def, q.quote.total); + priceForDriver = split.forDriver; + commissionAmount = split.commission; + commissionRate = split.rate; + } } catch { this.logger.warn(`no tariff for ${city}/${serviceClass} — trip without quote`); } @@ -142,6 +155,10 @@ export class TripsService { tariff_id: tariffId, tariff_version: tariffVersion, quoted_fare: quotedFare, + price_for_passenger: quotedFare, + price_for_driver: priceForDriver, + commission_amount: commissionAmount, + commission_rate: commissionRate, currency: currency ?? undefined, payment_method: (dto as any).payment_method ?? 'cash', }); @@ -183,10 +200,17 @@ export class TripsService { throw new BadRequestException('Trip is no longer available'); } - const trip = await this.getOr404(tenantId, tripId); + let trip = await this.getOr404(tenantId, tripId); // مشغول الآن: يخرج من فهرس المتاحين فلا تُعرض عليه رحلة أخرى (H5). await this.locations.setAvailability(tenantId, driver.id, driver.service_class, 'busy'); + // مشوار وصول السائق للراكب — يُقاس من موقعه لحظة القبول (docs/17 — B3). + const pickupLeg = await this.measurePickupLeg(tenantId, driver.id, trip); + if (pickupLeg) { + await this.trips.update({ tenant_id: tenantId, id: tripId }, pickupLeg); + trip = await this.getOr404(tenantId, tripId); + } + await this.recordEvent(trip, 'searching', 'assigned', 'driver'); const driverUser = await this.users.findById(tenantId, driverUserId); await this.notifyParties(tenantId, trip, 'assigned', driverUserId, { @@ -215,27 +239,50 @@ export class TripsService { const snapshot = await this.snapshot(tenantId, tripId); this.assertTransition(snapshot.status, toStatus); + const now = new Date(); const patch: Partial = { status: toStatus }; - // كشف احتيال عند نقاط حسّاسة — الموقع الحيّ من Redis، لا صف السائق. - if (toStatus === 'driver_arrived' && snapshot.driver_id && snapshot.driver_user_id) { - const at = await this.locations.get(tenantId, snapshot.driver_id); - await this.fraud.checkArrivedProximity( - tenantId, - snapshot.driver_user_id, - at ? { lat: at.lat, lng: at.lng } : null, - { lat: snapshot.origin_lat, lng: snapshot.origin_lng }, - tripId, - ); + // طوابع دقيقة لكل مرحلة (docs/17 — B7). + if (toStatus === 'driver_arriving') patch.driver_going_at = now; + + if (toStatus === 'driver_arrived') { + patch.arrived_at = now; // بداية عدّ الانتظار (B2) + // كشف احتيال — الموقع الحيّ من Redis، لا صف السائق. + if (snapshot.driver_id && snapshot.driver_user_id) { + const at = await this.locations.get(tenantId, snapshot.driver_id); + await this.fraud.checkArrivedProximity( + tenantId, + snapshot.driver_user_id, + at ? { lat: at.lat, lng: at.lng } : null, + { lat: snapshot.origin_lat, lng: snapshot.origin_lng }, + tripId, + ); + } } + + if (toStatus === 'in_progress') { + patch.started_at = now; // rideTimeStart — الراكب صعد فعلاً + // انتظر السائق من لحظة وصوله حتى الآن؛ أول 5 دقائق مجانية (B2). + if (snapshot.arrived_at) { + const waitedMin = (now.getTime() - snapshot.arrived_at.getTime()) / 60000; + const def = await this.tariffDef(tenantId, snapshot); + patch.waiting_min = Number(waitedMin.toFixed(1)); + patch.waiting_charge = def + ? TariffEngine.waitingCharge(def, waitedMin, now).charge + : 0; + } + } + if (toStatus === 'completed') { - patch.completed_at = new Date(); - if (snapshot.quoted_fare != null) patch.final_fare = snapshot.quoted_fare; + patch.completed_at = now; // rideTimeFinish + Object.assign(patch, await this.settleFare(tenantId, tripId, snapshot)); if (snapshot.driver_user_id) { await this.fraud.checkFastCompletion( tenantId, snapshot.driver_user_id, - snapshot.assigned_at, + // من لحظة **بدء** الرحلة لا إسنادها: مشوار الوصول والانتظار ليسا + // جزءاً من زمنها، وإلا بدت كل رحلة أطول مما هي (docs/17 — B7). + snapshot.started_at ?? snapshot.assigned_at, snapshot.distance_km, tripId, ); @@ -255,20 +302,26 @@ export class TripsService { await this.syncState(trip, snapshot, toStatus); await this.recordEvent(trip, snapshot.status, toStatus, actor); - // تسوية المحفظة عند الدفع (payment_method === wallet) + // تسوية المحفظة عند الدفع (payment_method === wallet). + // الراكب يُخصم منه price_for_passenger، والسائق يُضاف له price_for_driver — + // الفرق هو عمولة المنصة (docs/17 — B6). كانا متساويين قبل فصل السعر، أي + // أن العمولة كانت تضيع. if (toStatus === 'paid' && trip.payment_method === 'wallet' && trip.final_fare != null) { - const fare = Number(trip.final_fare); + const passengerPays = Number(trip.price_for_passenger ?? trip.final_fare); + const driverEarns = Number(trip.price_for_driver ?? trip.final_fare); try { - await this.wallet.debit(tenantId, trip.rider_id, fare, 'trip_fare', trip.id); - if (snapshot.driver_user_id) { + await this.wallet.debit(tenantId, trip.rider_id, passengerPays, 'trip_fare', trip.id); + if (snapshot.driver_user_id && driverEarns > 0) { await this.wallet.credit( tenantId, snapshot.driver_user_id, - fare, + driverEarns, 'trip_earning', trip.id, ); } + // TODO(I2): إيداع commission_amount في محفظة المنصة (مكافئ siroWallet). + // حتى تُبنى، العمولة محسوبة ومسجّلة على الرحلة لكنها لا تُقيَّد في محفظة. } catch (e: any) { this.logger.warn(`wallet settle failed: ${e?.message}`); } @@ -385,7 +438,84 @@ export class TripsService { distance_km: trip.distance_km == null ? null : Number(trip.distance_km), quoted_fare: trip.quoted_fare == null ? null : Number(trip.quoted_fare), currency: trip.currency ?? null, + city: trip.city ?? 'default', assigned_at: trip.assigned_at, + arrived_at: trip.arrived_at, + started_at: trip.started_at, + }; + } + + /** + * يقيس مشوار السائق حتى الراكب لحظة القبول (docs/17 — B3). + * يرجع null لو تعذّر — قياس المشوار لا يجوز أن يُفشل قبول الرحلة. + */ + private async measurePickupLeg( + tenantId: string, + driverId: string, + trip: Trip, + ): Promise | null> { + try { + const at = await this.locations.get(tenantId, driverId); + if (!at) return null; + + const leg = await this.maps.route( + { lat: at.lat, lng: at.lng }, + { lat: trip.origin_lat, lng: trip.origin_lng }, + ); + const def = await this.tariff + .getActive(tenantId, trip.city ?? 'default', trip.service_class) + .then((t) => t?.definition ?? null); + + return { + pickup_distance_km: leg.distanceKm, + pickup_duration_min: leg.durationMin, + pickup_charge: def + ? TariffEngine.pickupCharge(def, leg.distanceKm, leg.durationMin) + : 0, + }; + } catch (e: any) { + this.logger.warn(`pickup leg measure failed: ${e?.message}`); + return null; + } + } + + /** تعريف التعرفة الفعّالة للرحلة (من كاش Redis — docs/17 G5). */ + private async tariffDef(tenantId: string, snapshot: TripState) { + const t = await this.tariff.getActive(tenantId, snapshot.city, snapshot.service_class); + return t?.definition ?? null; + } + + /** + * الأجرة النهائية وفصلها (docs/17 — B2/B3/B6). + * الراكب يدفع: السعر المقفول + انتظاره الزائد + مشوار الوصول (إن فُعِّل). + * السائق يقبض: ذلك ناقص عمولة المنصة. + */ + private async settleFare( + tenantId: string, + tripId: string, + snapshot: TripState, + ): Promise> { + // الانتظار ومشوار الوصول حُسبا وخُزّنا سابقاً — نقرأهما من الصف. + const row = await this.trips.findOne({ + where: { tenant_id: tenantId, id: tripId }, + select: { waiting_charge: true, pickup_charge: true }, + }); + const base = snapshot.quoted_fare ?? 0; + const waiting = Number(row?.waiting_charge ?? 0); + const pickup = Number(row?.pickup_charge ?? 0); + const total = Number((base + waiting + pickup).toFixed(3)); + + const def = await this.tariffDef(tenantId, snapshot); + const split = def + ? TariffEngine.split(def, total) + : { forPassenger: total, forDriver: total, commission: 0, rate: 0 }; + + return { + final_fare: split.forPassenger, + price_for_passenger: split.forPassenger, + price_for_driver: split.forDriver, + commission_amount: split.commission, + commission_rate: split.rate, }; } @@ -424,6 +554,8 @@ export class TripsService { tripDistanceKm: trip.distance_km == null ? null : Number(trip.distance_km), tripDurationMin: trip.duration_min == null ? null : Number(trip.duration_min), quotedFare: trip.quoted_fare == null ? null : Number(trip.quoted_fare), + // ما يقبضه السائق فعلاً — لا أجرة الراكب (docs/17 — B6). + earnings: trip.price_for_driver == null ? null : Number(trip.price_for_driver), currency: trip.currency ?? null, serviceClass: trip.service_class, paymentMethod: trip.payment_method, diff --git a/docs/17-backend-backlog.md b/docs/17-backend-backlog.md index d96c54e..1a8d3ea 100644 --- a/docs/17-backend-backlog.md +++ b/docs/17-backend-backlog.md @@ -22,22 +22,25 @@ --- -## المجموعة B — اكتمال نموذج الرحلة +## المجموعة B — اكتمال نموذج الرحلة — 🔵 الشريحة الأولى منفَّذة (المال والزمن) > مقارنة بجدول `ride` في سيرو + قواعد التشغيل. -| # | البند | التفصيل | -|---|-------|---------| -| B1 | **حالة `started` وفصل الوصول عن البدء** | السائق وصل ≠ الرحلة بدأت. الراكب قد لا يرد/يلغي. نحتاج تمييزاً صريحاً. | -| B2 | **عدّاد انتظار 5 دقائق (قانون)** | يبدأ عند وصول السائق (بعد **تأكيد المسافة** من فلاتر). عند انتهائه: خيار للراكب — يبقى السائق منتظراً، أو **تعويض** فترة الانتظار. | -| B3 | **احتساب مشوار الوصول للراكب** | المسافة/الزمن من موقع السائق حتى الراكب تُحتسب بالتعرفة (دقائق + مسافة). | -| B4 | **نقاط توقف (stops)** | نقطتا توقف ضمن الرحلة (كما في سيرو/شير). | -| B5 | **حجز مسبق/جدولة** | جداول الرحلة (`date`/`time`/`endtime` في سيرو). | -| B6 | **فصل السعر** | `price_for_driver` مقابل `price_for_passenger` (العمولة) — **ناقص عندنا**. | -| B7 | **طوابع زمنية دقيقة** | `DriverIsGoingToPassenger` · `rideTimeStart` · `rideTimeFinish` (سيرو) — عندنا `assigned_at`/`completed_at` فقط. | -| B8 | **مطابقة الوجهة** | `is_destination_match` + خصم الراكب. | -| B9 | **تعديل التعرفة من لوحة الأدمن** | جداول تعرفة قابلة للتحرير (موجودة كـ jsonb — نحتاج واجهة/نقاط CRUD). | -| B10 | **كتالوج أنواع الرحلات العالمي** | `ride_types` فكرته سليمة (الأدمن/المستأجر يضيف أنواعه). المطلوب: كتالوج جاهز بما هو شائع عالمياً كخيارات جاهزة للاختيار (عندنا 6 فقط الآن). | -| B11 | **تعرفة بالوزن** | بُعد تسعير إضافي بالوزن (للشحن/التوصيل) بجانب المسافة والزمن. الاستعلام عن التعرفة من Redis خط أول (G5). | +| # | البند | التفصيل | الحالة | +|---|-------|---------|--------| +| B1 | **حالة `started` وفصل الوصول عن البدء** | — | ✅ **كان منفَّذاً أصلاً**: `driver_arrived` و`in_progress` حالتان منفصلتان في آلة الحالة منذ P1. الناقص كان الطوابع والانتظار (B2/B7) لا الحالة نفسها. | +| B2 | **عدّاد انتظار 5 دقائق (قانون)** | يبدأ عند `driver_arrived`، ويُحتسب عند `in_progress`. الدقائق المجانية `free_waiting_min` (5 افتراضاً) قابلة للضبط لكل تعرفة، والزائد بسعر `per_min_waiting` للنافذة الفعّالة. | ✅ `waiting_min` + `waiting_charge` | +| B3 | **احتساب مشوار الوصول للراكب** | يُقاس من موقع السائق **الحيّ لحظة القبول** حتى نقطة الالتقاط. **لا يُحتسب على الراكب ما لم يُفعِّله المالك** (`pickup_leg.charge`) — تفعيله يرفع كل أجرة، فهو قرار لا افتراض صامت. | ✅ `pickup_distance_km`/`_duration_min`/`_charge` | +| B6 | **فصل السعر** | `price_for_passenger` · `price_for_driver` · `commission_amount` · `commission_rate`. العمولة تُعرَّف في التعرفة: `commission: { percent, flat, min }`، ولا تتجاوز الأجرة أبداً. | ✅ + **تسوية المحفظة صُحّحت**: كانت تُضيف للسائق كامل أجرة الراكب — أي أن العمولة كانت تضيع | +| B7 | **طوابع زمنية دقيقة** | `driver_going_at` (`DriverIsGoingToPassenger`) · `arrived_at` · `started_at` (`rideTimeStart`) · `completed_at` (`rideTimeFinish`). | ✅ + كشف «الإنهاء السريع» صار يقيس من **بدء** الرحلة لا إسنادها | +| B4 | **نقاط توقف (stops)** | نقطتا توقف ضمن الرحلة (كما في سيرو/شير). | ⏳ الشريحة التالية | +| B5 | **حجز مسبق/جدولة** | جداول الرحلة (`date`/`time`/`endtime` في سيرو). | ⏳ الشريحة التالية | +| B8 | **مطابقة الوجهة** | `is_destination_match` + خصم الراكب. | ⏳ الشريحة التالية | +| B9 | **تعديل التعرفة من لوحة الأدمن** | جداول تعرفة قابلة للتحرير (موجودة كـ jsonb — نحتاج واجهة/نقاط CRUD). | ⏳ | +| B10 | **كتالوج أنواع الرحلات العالمي** | `ride_types` فكرته سليمة (الأدمن/المستأجر يضيف أنواعه). المطلوب: كتالوج جاهز بما هو شائع عالمياً كخيارات جاهزة للاختيار (عندنا 6 فقط الآن). | ⏳ | +| B11 | **تعرفة بالوزن** | بُعد تسعير إضافي بالوزن (للشحن/التوصيل) بجانب المسافة والزمن. | ⏳ | + +**الأجرة النهائية الآن** = السعر المقفول + رسم الانتظار الزائد + مشوار الوصول (إن فُعِّل)، ثم تُفصل: الراكب يدفع `price_for_passenger`، والسائق يقبض `price_for_driver`، والفرق عمولة. +**معلَّق على I2**: العمولة محسوبة ومسجّلة على الرحلة لكنها **لا تُقيَّد في محفظة المنصة** — لأن محفظة المنصة (مكافئ `siroWallet`) لم تُبنَ بعد. --- @@ -187,7 +190,7 @@ 2. ~~**I1** (سباق المحفظة)~~ ✅ **منفَّذة ومُثبَتة بتزامن حقيقي**. 3. ~~**G** (Redis خط أول: لغة/توكنات/tenant/تعرفة/تقييم/كاش الخرائط)~~ ✅ **منفَّذة**. 4. ~~**H** (المواقع: إيقاف الكتابة لكل نبضة + batching + tracks)~~ ✅ **منفَّذة** (عدا H6/H7 — تحليلات مؤجَّلة). -5. **B** (نموذج الرحلة: started/انتظار/فصل السعر/الطوابع/stops). +5. **B** — 🔵 الشريحة الأولى ✅ (الانتظار B2 · مشوار الوصول B3 · فصل السعر B6 · الطوابع B7). الباقي: B4 stops · B5 جدولة · B8 مطابقة الوجهة · B9 · B10 · B11. 6. **I** الباقي (المدفوعات: جداول لكل طريقة + OTP/بصمة/HMAC للسحب). 7. **C** (بيانات المركبة والسائق + ai_data). 8. **D** (تطبيع الهاتف + بصمة الجهاز + HMAC).