# BEEB BEEB — Ride Calculation Reference

This document describes **every way ride-related amounts are calculated** in the current BEEB BEEB backend. It is intended for backend developers who need to replicate the same logic in another service or codebase.

**Currency:** SAR (Saudi Riyal) everywhere.

**Primary implementation:** `app/Services/Client/RideFlowService.php`

---

## 1. Configuration (site settings)

All pricing thresholds come from `settings()` (`app/Services/SettingService.php`), seeded in `database/seeders/SettingSeeder.php`.

| Setting key | Default | Used for |
|-------------|---------|----------|
| `ride_price_per_minute` | `0.45` | Per-minute ride charge |
| `min_ride_balance` | `5` | Minimum wallet balance required to **start** a ride (scan) |
| `low_balance_threshold` | `10` | UI flag `balance_low` + low-balance notifications |
| `default_profit_rate` | `18` | Default provider profit % (overridden per provider) |
| `vat_rate` | `15` | Stored in settings; **not applied** to ride `cost` today |

Per-scooter `price_per_minute` may exist on the model, but the ride engine always uses the **global** `ride_price_per_minute` setting. The map API only **displays** the global rate on scooters.

---

## 2. When calculations run

| Ride status | Billing? | Notes |
|-------------|----------|-------|
| `scanning` | No | Pre-ride balance check only |
| `active` | Live estimate only | `liveMetrics()` — no wallet charge yet |
| `ending` | **Yes — final charge** | Cost computed and wallet deducted in `end()` |
| `completed` | Frozen | `cost` stored on ride record |
| `cancelled` | No | Status exists; no cancel/charge API implemented |

### Ride lifecycle

```
SCANNING → ACTIVE → ENDING → COMPLETED
                              ↑
                    (parking photo upload)
```

- **Scan** (`scan`): checks wallet ≥ `min_ride_balance`, creates ride.
- **Unlock** (`unlock`): sets `started_at = now()`, status `active`. No charge.
- **End** (`end`): computes duration + cost, deducts wallet, status `ending`.
- **Parking photo** (`uploadParkingPhoto`): status `completed`. No extra charge.

---

## 3. Core pricing formula

Rides are billed **by elapsed time only**. Distance does **not** affect cost.

### 3.1 Duration

```text
ended_at   = now() at end time
duration_seconds = max(1, ended_at - started_at in seconds)
elapsed_minutes  = ceil(duration_seconds / 60)   // always round UP
```

Examples:

| Actual ride time | `duration_seconds` | `elapsed_minutes` |
|------------------|------------------|-------------------|
| 1 second | 1 | 1 |
| 59 seconds | 59 | 1 |
| 60 seconds | 60 | 1 |
| 61 seconds | 61 | 2 |
| 2 min 30 sec | 150 | 3 |

### 3.2 Base cost (no subscription)

```text
price_per_minute = settings.ride_price_per_minute   // default 0.45
cost = round(elapsed_minutes × price_per_minute, 2)
```

### 3.3 Cost with active subscription

An active subscription must satisfy **all** of:

- `is_active = true`
- `ends_at > now()`
- Latest row by `starts_at` wins if multiple exist

```text
minute_cap = subscription.package.minute_cap
available_minutes = (minute_cap is null) ? 999999 : max(0, minute_cap - subscription.minutes_used)

if elapsed_minutes <= available_minutes:
    package_minutes_used = elapsed_minutes
    uncovered_minutes    = 0
else:
    package_minutes_used = available_minutes
    uncovered_minutes    = elapsed_minutes - available_minutes

subscription.minutes_used += package_minutes_used   // persisted on end

cost = round(uncovered_minutes × price_per_minute, 2)
```

**Important:**

- Package minutes are consumed even when `cost = 0`.
- If `minute_cap` is `null`, the package is treated as **unlimited minutes** (`available_minutes = 999999`).
- Subscription is loaded with `lockForUpdate()` inside the end transaction to avoid race conditions.

### 3.4 Wallet deduction

After cost is computed:

```text
if cost > 0:
    wallet.deduct(client, cost, type = "ride")
```

`WalletService::deduct()` (`app/Services/Entity/WalletService.php`):

- Fails with HTTP 400 if `wallet.balance < cost` (`not_enough_balance`).
- Creates a withdrawal transaction with `type = ride`.
- Sends `LOW_BALANCE` notification if new balance `< 10` (hardcoded, not the settings threshold).

If `cost = 0` (fully covered by package), **no wallet transaction** is created.

---

## 4. Pre-ride balance checks

### 4.1 Scan gate

At scan time:

```text
balance     = client.wallet.balance ?? 0
min_balance = settings.min_ride_balance   // default 5

if balance < min_balance → reject (insufficient_balance_for_ride)
```

No cost projection is done at scan — only the minimum balance floor.

### 4.2 `can_start_ride` (profile)

From `ClientHelpers::canStartRide()`:

```text
can_start_ride =
    client is active
    AND not blocked
    AND not in_ride
    AND email verified (profile complete)
    AND wallet.balance >= min_ride_balance
```

### 4.3 `balance_low` flag

```text
balance_low = wallet.balance < settings.low_balance_threshold   // default 10
```

Used in profile, home, and wallet APIs. Separate from `min_ride_balance`.

---

## 5. Live metrics (in-ride estimate)

`RideFlowService::liveMetrics()` is called on scan, unlock, end, active-ride poll, and by the notification cron.

Uses the **same pricing logic** as `end()`, but:

- End time = `ride.ended_at ?? now()` (for active rides, clock keeps ticking).
- `remaining_balance` = current wallet balance (not projected after deduction).
- Does **not** mutate subscription or wallet.

### Returned fields

| Field | Calculation |
|-------|-------------|
| `elapsed_seconds` | `max(0, endTime - started_at)` |
| `current_cost` | Same formula as final `cost` |
| `remaining_balance` | `round(max(0, wallet.balance), 2)` |
| `duration_formatted` | `MM:SS` from elapsed seconds |
| `distance_km` | **Estimate only** — see §6 |
| `has_active_package` | Whether subscription applies |
| `package_minutes_used` | Minutes that would be charged to package |
| `package_minutes_remaining` | `available - elapsed` or `null` if unlimited cap |

`RideResource` falls back to live values when stored `cost`, `duration_seconds`, or `distance_km` are zero (e.g. ride still active).

---

## 6. Distance — informational only

**Distance is never multiplied into ride cost.**

### 6.1 Live distance estimate

During an active ride:

```text
distance_km = round((elapsed_seconds / 3600) × 15.0, 3)
```

This assumes a fixed **15 km/h** average speed. It is for UI display only.

### 6.2 Stored distance on end

```text
ride.distance_km = request.distance_km ?? ride.distance_km ?? 0
```

The public `end` API controller currently validates only `lat`, `lng`, `map_desc` — **`distance_km` is not accepted from the client in that controller**, but the service layer supports it if passed (e.g. auto-termination cron passes the live estimate).

Distance appears on receipts, trip stats, and admin views but does not change billing.

---

## 7. Mid-ride balance depletion (cron)

Command: `php artisan notifications:check-triggers`  
File: `app/Console/Commands/CheckNotificationTriggers.php`

For rides in `scanning` or `active`:

```text
metrics = liveMetrics(ride, client)

depleted if:
    NOT metrics.has_active_package
    AND wallet.balance < (metrics.current_cost + price_per_minute)
```

Flow:

1. First detection → send `BALANCE_DEPLETED_MID_RIDE`, cache timestamp for 30 min.
2. If still depleted **≥ 5 minutes** later → auto-call `end()` with estimated distance and last known coordinates.
3. Auto-end runs the normal cost + wallet deduction logic.

Clients with an active package are **skipped** (package-covered minutes do not trigger depletion).

---

## 8. Provider / investor revenue

Provider earnings are derived from **completed ride `cost`**, not from distance or duration directly.

### 8.1 Profit rate

```text
profit_rate = provider.profit_rate ?? 18   // percent
```

### 8.2 Per-ride provider share

```text
provider_share = round(ride.cost × profit_rate / 100, 2)
```

Used in:

- `ProviderDashboardService` — revenue KPIs, charts, per-scooter tables
- `SettlementService` — settlement requests

### 8.3 Revenue aggregation

```text
revenue = SUM(ride.cost)
  WHERE ride.provider_id = provider.id
    AND ride.status = 'completed'
    AND ended_at in [period]   // optional date filter
```

Provider profit:

```text
profit = revenue × profit_rate / 100
```

### 8.4 Settlements

Settleable rides: `status = completed`, not already in a `pending` or `accepted` settlement.

On settlement request:

```text
total_revenue = SUM(ride.cost) for all settleable rides
total_amount  = round(total_revenue × profit_rate / 100, 2)

per ride pivot:
  ride_cost    = round(ride.cost, 2)
  share_amount = round(ride.cost × profit_rate / 100, 2)
```

**Note:** Package-covered rides with `cost = 0` contribute **0** to provider revenue and settlements.

---

## 9. Client trip stats

`GET /api/client/rides/stats` (`RideController::stats`):

```text
total_spent     = SUM(cost)           // completed rides only
riding_hours    = round(SUM(duration_seconds) / 3600, 2)
distance_km     = round(SUM(distance_km), 2)
completed_rides = COUNT(*)
```

---

## 10. Receipt display

PDF receipt (`resources/views/pdf/receipt.blade.php`):

- **Cost:** `ride.cost` (2 decimal places)
- **Duration shown:** `ceil(duration_seconds / 60)` minutes (same ceiling rule)
- **Distance:** `ride.distance_km` as stored

---

## 11. Worked examples

Assume `ride_price_per_minute = 0.45`.

### Example A — No package, 2 min 10 sec ride

```text
duration_seconds = 130
elapsed_minutes  = ceil(130/60) = 3
cost             = round(3 × 0.45, 2) = 1.35 SAR
wallet deducted  = 1.35
```

### Example B — Package with 60 min cap, 45 used, 20 min ride

```text
available_minutes  = 60 - 45 = 15
elapsed_minutes    = 20
package_minutes_used = 15
uncovered_minutes    = 5
cost                 = round(5 × 0.45, 2) = 2.25 SAR
minutes_used after   = 45 + 15 = 60
```

### Example C — Package fully covers ride

```text
available_minutes = 30
elapsed_minutes = 12
uncovered_minutes = 0
cost = 0.00
minutes_used += 12
no wallet transaction
```

### Example D — Provider share

```text
ride.cost = 4.50
profit_rate = 18%
provider_share = round(4.50 × 18 / 100, 2) = 0.81 SAR
```

---

## 12. Pseudocode (end ride — full)

```text
function endRide(ride, data):
    assert ride.status == ACTIVE
    assert ride.client_id == authenticated_client

    endedAt = now()
    durationSeconds = max(1, secondsBetween(ride.started_at, endedAt))
    elapsedMinutes = ceil(durationSeconds / 60)
    pricePerMinute = settings.ride_price_per_minute

    BEGIN TRANSACTION
        subscription = findActiveSubscription(ride.client_id) FOR UPDATE

        cost = 0
        packageMinutesUsed = 0

        if subscription:
            available = subscription.package.minute_cap == null
                ? 999999
                : max(0, subscription.package.minute_cap - subscription.minutes_used)

            if elapsedMinutes <= available:
                packageMinutesUsed = elapsedMinutes
                uncovered = 0
            else:
                packageMinutesUsed = available
                uncovered = elapsedMinutes - available

            if packageMinutesUsed > 0:
                subscription.minutes_used += packageMinutesUsed

            cost = round(uncovered * pricePerMinute, 2)
        else:
            cost = round(elapsedMinutes * pricePerMinute, 2)

        ride.update(
            status: ENDING,
            ended_at: endedAt,
            duration_seconds: durationSeconds,
            distance_km: data.distance_km ?? ride.distance_km,
            cost: cost,
            end_lat, end_lng, end_address from data
        )

        if cost > 0:
            wallet.deduct(ride.client, cost, RIDE)
    COMMIT

    return ride
```

---

## 13. What is **not** calculated today

| Topic | Status |
|-------|--------|
| Distance-based pricing | Not implemented |
| VAT on ride cost | Setting exists; not applied to `cost` |
| Cancellation fee | `cancelled` status exists; no charge flow |
| Per-scooter custom rate at billing | Map may show scooter rate; billing uses global setting |
| Partial-minute proration below 1 minute | Minimum billable unit is 1 minute (`ceil`) |
| Charging during `scanning` / before unlock | No charge |
| Second charge on parking photo / rating | No charge |

---

## 14. Source file map

| Concern | File |
|---------|------|
| Final cost + package minutes | `app/Services/Client/RideFlowService.php` |
| Live in-ride cost | `app/Services/Client/RideFlowService.php` → `liveMetrics()` |
| Wallet deduct | `app/Services/Entity/WalletService.php` |
| Pre-ride balance rules | `app/Models/Helpers/ClientHelpers.php` |
| API orchestration | `app/Http/Controllers/Api/Client/RideFlowController.php` |
| Trip stats aggregation | `app/Http/Controllers/Api/Client/RideController.php` |
| Mid-ride auto-end | `app/Console/Commands/CheckNotificationTriggers.php` |
| Provider revenue / share | `app/Services/Provider/ProviderDashboardService.php` |
| Settlement amounts | `app/Services/Settlement/SettlementService.php` |
| Subscription purchase (separate from ride) | `app/Http/Controllers/Api/Client/SubscriptionController.php` |
| Settings defaults | `database/seeders/SettingSeeder.php` |
| Ride DB columns | `database/migrations/beeb/2026_06_02_120200_create_rides_table.php` |

---

## 15. Replication checklist

To match BEEB BEEB behavior in another backend:

1. Bill by **ceiled whole minutes** from `started_at` to end time (minimum 1 second → 1 minute).
2. Use global **price per minute**; ignore distance for cost.
3. Apply **subscription minute cap** before charging wallet; increment `minutes_used` on end.
4. Deduct wallet **once** at end; fail if balance insufficient.
5. Gate scan on `min_ride_balance` (default 5 SAR).
6. Expose live cost with the same formula but without persisting until end.
7. Provider share = `ride.cost × profit_rate%` on completed rides only.
8. Treat `distance_km` as metadata / UI estimate (15 km/h live formula).
