feat: implement rewards module with referral/coupon systems, update tariff seeding, and add trip distance service for improved billing accuracy.

This commit is contained in:
Hamza-Ayed
2026-07-19 00:20:37 +03:00
parent 22d18f32fe
commit 92f1eeb48c
39 changed files with 3612 additions and 208 deletions
+67 -6
View File
@@ -28,7 +28,7 @@ export interface QuoteBreakdown {
export class TariffEngine {
static quote(def: TariffDefinition, input: QuoteInput): QuoteBreakdown {
const at = input.at ?? new Date();
const win = TariffEngine.pickWindow(def.windows, at);
const win = TariffEngine.pickWindow(def.windows, at, def.timezone);
const distanceKm = Math.max(0, input.distanceKm);
const durationMin = Math.max(0, input.durationMin);
const waitingMin = Math.max(0, input.waitingMin ?? 0);
@@ -36,6 +36,36 @@ export class TariffEngine {
let distanceCharge = 0;
let timeCharge = 0;
if (def.mode === 'fixed_quote') {
// سعر ثابت متفَق عليه (خط ثابت / وجهة بسعر معلن): لا مسافة ولا زمن.
// كان يسقط سابقاً إلى الفرع المتري فيُحسب بالعدّاد رغم اسمه — أي أن
// «السعر الثابت» لم يكن ثابتاً أبداً.
const fixed = Math.max(0, def.fixed_fare ?? 0);
const waitingOnly = waitingMin * (win.per_min_waiting ?? 0);
// الانتظار وحده يُضاف: بقاء الراكب ربع ساعة تكلفة حقيقية على السائق
// مهما كان الخط ثابتاً. ولا حدّ أدنى هنا — السعر المعلن **هو** الاتفاق،
// ورفعه بحدّ أدنى يعني إعلان سعر وتحصيل غيره.
const multiplier =
(def.surge?.enabled && def.surge.multiplier ? def.surge.multiplier : 1) *
(input.geofenceMultiplier ?? 1.0);
const fixedTotal = TariffEngine.round(
(fixed + waitingOnly) * multiplier,
def.rounding,
);
return {
window: win.name,
flag: 0,
distance: 0,
time: 0,
waiting: TariffEngine.n(waitingOnly),
bookingFee: 0,
subtotal: TariffEngine.n(fixed + waitingOnly),
surgeMultiplier: multiplier,
total: fixedTotal,
currency: def.currency,
};
}
if (def.mode === 'time_or_distance') {
// العداد المنظَّم: تحت العتبة يُحسب بالدقيقة (زحمة)، فوقها بالكيلومتر.
const avgSpeed = durationMin > 0 ? (distanceKm / durationMin) * 60 : 999;
@@ -46,7 +76,7 @@ export class TariffEngine {
distanceCharge = distanceKm * win.per_km;
}
} else {
// time_and_distance (و fixed_quote يُحسب مرة ويثبّت)
// time_and_distance — العدّاد المعتاد: فتحة + مسافة + زمن.
distanceCharge = distanceKm * win.per_km;
timeCharge = durationMin * win.per_min;
}
@@ -91,7 +121,7 @@ export class TariffEngine {
waitedMin: number,
at: Date = new Date(),
): { freeMin: number; billableMin: number; charge: number } {
const win = TariffEngine.pickWindow(def.windows, at);
const win = TariffEngine.pickWindow(def.windows, at, def.timezone);
const freeMin = def.free_waiting_min ?? 5;
const billableMin = Math.max(0, (waitedMin ?? 0) - freeMin);
return {
@@ -114,7 +144,7 @@ export class TariffEngine {
durationMin: number,
at: Date = new Date(),
): number {
const win = TariffEngine.pickWindow(def.windows, at);
const win = TariffEngine.pickWindow(def.windows, at, def.timezone);
const charge =
Math.max(0, distanceKm ?? 0) * win.per_km + Math.max(0, durationMin ?? 0) * win.per_min;
return TariffEngine.n(charge);
@@ -144,8 +174,39 @@ export class TariffEngine {
return { amount: TariffEngine.n(amount), rate: percent };
}
private static pickWindow(windows: TariffWindow[], at: Date): TariffWindow {
const mins = at.getUTCHours() * 60 + at.getUTCMinutes();
/**
* دقائق اليوم بالتوقيت **المحلي للتعرفة** لا بـUTC ولا بتوقيت الخادم.
*
* `Intl` هو من يحمل جدول التوقيت الصيفي: مصر أعادت العمل به 2023، فأي
* إزاحة ثابتة محفورة في الكود كانت ستنحرف ساعةً كاملة نصف السنة —
* وساعة كاملة تعني تسعير ذروة على رحلات عادية.
*/
private static localMinutes(at: Date, timezone?: string): number {
const tz = timezone ?? 'Asia/Amman';
try {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: tz,
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).formatToParts(at);
const h = Number(parts.find((p) => p.type === 'hour')?.value);
const m = Number(parts.find((p) => p.type === 'minute')?.value);
// 24:00 بدل 00:00 يخرج من بعض الإصدارات — يُطبَّع لا يُمرَّر.
if (Number.isFinite(h) && Number.isFinite(m)) return (h % 24) * 60 + m;
} catch {
// منطقة زمنية مكتوبة خطأً في إعداد التعرفة: نسقط لتوقيت الخادم بدل
// أن نرمي — تسعير بنافذة تقريبية أهون من رحلة بلا سعر إطلاقاً.
}
return at.getHours() * 60 + at.getMinutes();
}
private static pickWindow(
windows: TariffWindow[],
at: Date,
timezone?: string,
): TariffWindow {
const mins = TariffEngine.localMinutes(at, timezone);
for (const w of windows) {
const [fromH, fromM] = w.from.split(':').map(Number);
const [toH, toM] = w.to.split(':').map(Number);