Skip to content

Free Ride Minutes Documentation

This document provides detailed technical documentation for "Free Ride Minutes" feature.

This project brings a major change to PAYG pricing, removing:

  • Daily Service Fee (DSF).
  • 10 min free daily.
  • 1£ promo bikes.

By introducing a dynamic, bike-based free minutes system. The goal is to make pricing clearer, while improving profitability by optimizing how and where we offer free minutes.

This feature is implemented in webhooks project


Credits Distribution and Assignment

This feature is designed to calculate and assign credits to vehicles based on a flexible and composable set of rules. The system is built on two primary abstractions:

  • CreditsDistributionStrategy: The "Rulebook." This is defined as an abstract class to interfaces for different methods of calculating a credit distribution. Current concrete implementation includes:

    • Weighted random assignments.
    • Geographical assignments.
    • Idle-based assignments.
  • CreditsAssignerVehicles: The "Applier." This class takes a pre-calculated distribution and uses a CreditsStorageStrategy (defined as an abstract class) to persist it. It also handles dry_run logic, so mock the persistence.

The core strength of this system is the CompositeDistributionStrategy, which allows for composite layering multiple strategies. For example, you can apply a baseline distribution to all vehicles and then override specific vehicles with higher credits based on their location or idle status.

Requirements

This feature relies on previous implemented features to retreive and verifi geological data in GeoJson format.

PreparedGDFFromTerritoryAPI class: Extended class to work with territory data from the API response. Inherits from PreparedGDFFromGeoJSON to reuse the point checking logic.

Core Components

  • CreditsDistributionStrategy (abstract class): Interface for credit calculation.

  • WeightedRandomDistributionStrategy: Assigns credits to the entire fleet based on predefined weights:

    10: 0.10 # 10 credits -> 10% of fleet
    5: 0.10  # 5 credits -> 10% of fleet
    2: 0.50  # 2 credits -> 50% of fleet
    1: 0.30  # 1 credit -> 30% of fleet
    0: 0.00, # 0 credits -> 0% of fleet
    
  • GeographicalDistributionStrategy: Assigns specific credits to vehicles within a defined geographic area (GeoJSON).

  • IdleDistributionStrategy: Assigns specific credits to vehicles that have been idle (according to Forest/Wunder definition of idle) for a set duration (e.g., 72+ hours).

  • DowngradeDistributionStrategy: Reduces credits for vehicles that meet certain criteria (e.g., have 5+ credits).

  • CompositeDistributionStrategy: Combines multiple strategies, applying them in order and overriding the baseline distribution.

  • CreditsAssignerVehicles: Service class that orchestrates the saving of the distribution. It is agnostic to how the distribution was calculated.

  • CreditsStorageStrategy (abstract class): Interface for saving and retrieving credit data.

    • DatabaseCreditsStorage: The concrete implementation that saves the distribution to a database table (vehicle_credits_distribution_history) in Subabase DB service.
    • Database tables:

      • vehicle_credits_distribution_history -> for historical data
      • vehicle_credits_distribution -> for last assignments. (populated by a trigger on creation from vehicle_credits_distribution_history)

Application Flow Diagram

This diagram shows an example of end-to-end process, from fetching data to the final storage of credit assignments. The key part is the Composite Strategy, which applies individual strategies sequentially.

graph TD
    A((Start)) --> B["Fetch Fleet Data"]
    B --> C(["CompositeDistributionStrategy"])

    subgraph 1_Calculate_Distribution["1. Calculate Distribution"]
        C --> S1["Apply Strategy 1: WeightedRandom (Base Layer)"]
        S1 --> S2["Apply Strategy 2: Geographical - Camden (Overrides)"]
        S2 --> S3["Apply Strategy 3: Geographical - General use (Overrides)"]
        S3 --> S4["Apply Strategy 4: Idle (Overrides)"]
    end

    S4 --> D(["Final Distribution"])

    subgraph 2_Assign_and_Store["2. Assign & Store"]
        D --> E["CreditsAssignerVehicles"]
        E --> F{"Dry Run?"}
        F -- No --> G["storage_strategy.store_credits()"]
        G --> H["DatabaseCreditsStorage"]
        H --> I["Execute Insert Query"]
        I --> J[(DB - Supabase)]
        F -- Yes --> K["Log: Dry run mode (No DB Write)"]
    end

    J --> Z((End))
    K --> Z((End))

Flow Explanation

  • Fetch Data: The process begins by fetching the current state of the whole fleet. It also be posible to do this process for a single vehicle.

  • Calculate Distribution: The CompositeDistributionStrategy is called. It iterates through its list of strategies, one by one.

    • Strategy 1 (Base): WeightedRandomDistributionStrategy is applied first, giving every vehicle in the fleet a base number of credits (e.g., 1, 2, 5, or 10) as a base assignation.

    • Strategy 2-4 (Overrides): Geographical for Camden Boroguh specific, Geographical general use and Idle strategies are applied sequentially. These strategies override the credits for any vehicle that matches their specific criteria.

  • Final Distribution: The result is a single distribution dictionary mapping vehicle_id to its final VehicleCredits data (which includes the credit amount and the last strategy that was applied to it). Example:

        {
        14476: {
            "credits": 2,
            "strategy": "WeightedRandom"
            "lat": 51.518969,
            "lon": -0.100757, 
        },
        14477: {
            "credits": 5,
            "strategy": "Geographical",
            "lat": 51.518969,
            "lon": -0.100757,
        },
        ...
        }
    
  • Assign & Store: This final distribution is passed to CreditsAssignerVehicles.assign_credits() method implementation.

    • If dry_run is False, the assigner uses its DatabaseCreditsStorage to save all the records to the vehicle_credits_distribution_history table.

    • If dry_run is True, the process is only logged, and no data is saved.

Key Concept: Strategy Overriding

The most important concept to understand is the order of operations in the CompositeDistributionStrategy. Later strategies will overwrite the values set by earlier ones.

This allows for a powerful and flexible "base + override" pattern.

Example Scenario
  • Fleet: 1000 vehicles.
  • Composite Strategy Order:

    1. WeightedRandomDistributionStrategy
    2. GeographicalDistributionStrategy (for a specific "Camden" zone, assigns 10 credits)
    3. IdleDistributionStrategy (for vehicles idle > 72h, assigns 30 credits)
  • How a single vehicle is processed:

    Case 1:

  • Vehicle 123 (In Camden, Not Idle):

  • WeightedRandom strategy runs: Vehicle 123 is assigned 2 credits.
  • Geographical - Camden intance strategy runs: The vehicle is in the Camden zone. Its credits are overwritten to 10 credits.
  • Idle strategy runs: The vehicle is not idle. Its credits remain 10.
  • Final distribution:

      {
            123: {
            "credits": 10,
            "strategy":
            "Geographical_Camden", 
            ...
            }
      }
    

    Case 2:

    • Vehicle 456 (In Camden, AND Idle):
    • WeightedRandom strategy runs: Vehicle 456 is assigned 5 credits.
    • Geographical - Camden intance strategy runs: The vehicle is in the Camden zone. Its credits are overwritten to 10 credits.
    • Idle runs: The vehicle is idle. Its credits are overwritten to 30 credits. (Strategy: "Idle")
    • Final distribution:
        {
            123: {
                "credits": 30,
                "strategy": "Idle", 
                ...
            }
        }
    

    Case 3:

    • Vehicle 789 (NOT in Camden, NOT Idle):
    • WeightedRandom strategy runs: Vehicle 789 is assigned 2 credits.
    • Geographical - Camden intance strategy runs: No match. Credits remain 2.
    • Idle runs: No match. Credits remain 2.
    • Final distribution:
        {
            123: {
                "credits": 2,
                "strategy": "WeightedRandom", 
                ...
            }
        }
    

Example usage (python)

This is a simple implementation to illustrate the use of this strategies pattern.

Current implementation could be found in:

import asyncio
from models.dynamic_minutes import VehicleCredits
from freezegun import freeze_time

# --- Mock Data & Classes (for example only) ---
# In real use, these would be imported
class MockGeoData:
    def contains_point(self, lat, lon):
        # Mock: True for a specific point
        return lat == 51.5 and lon == -0.1

# Mock fleet data
mock_fleet = {
    123: {"vehicleId": 123, "lat": 51.5, "lon": -0.1, "vehicleStateId": 0, "isReserved": False, "timeLastMoved": "2020-01-01 12:00:00"}, # In geo, is idle
    456: {"vehicleId": 456, "lat": 51.5, "lon": -0.1, "vehicleStateId": 0, "isReserved": False, "timeLastMoved": None}, # In geo, is idle
    789: {"vehicleId": 789, "lat": 10.0, "lon": 10.0, "vehicleStateId": 0, "isReserved": False, "timeLastMoved": "2025-11-10 10:00:00"}, # Not in geo, not idle
}
# Mock the imported function for IdleStrategy
from functions import vehicles
vehicles.get_vehicles_by_last_action_log = lambda fleet, now: asyncio.sleep(0, fleet) 
# --- End Mocks ---

async def main():
    # 1. Prepare Geographical Data
    mock_gdf = MockGeoData()

    # 2. Instantiate individual strategies
    weighted_strategy = WeightedRandomDistributionStrategy()
    geo_strategy_camden = GeographicalDistributionStrategy(gdf=mock_gdf_camden, geo_credits=10, strategy_subname="Camden")
    idle_strategy = IdleDistributionStrategy(idle_credits=30)

    # 3. Create the Composite Strategy (ORDER MATTERS)
    # Base layer, then high-priority overrides, then a final cleanup/downgrade.
    composite_strategy = CompositeDistributionStrategy([
        weighted_strategy,
        geo_strategy_camden,
        idle_strategy,
    ])

    print("Calculating distribution...")
    # 4. Calculate the final distribution
    distribution = await composite_strategy.get_distribution(mock_fleet)

    print("--- Final Distribution ---")
    for vehicle_id, data in distribution.items():
        print(f"Vehicle {vehicle_id}: {data['credits']} credits (Strategy: {data['strategy']})")

    # 5. Initialize the Assigner
    # Set dry_run=False to actually save to the database
    assigner = CreditsAssignerVehicles(dry_run=True) 

    print("\nAssigning credits (Dry Run)...")
    # 6. Assign (i.e., save) the distribution
    await assigner.assign_credits(distribution)

    print("Process complete.")

@freeze_time("2025-11-10 10:30:00")
if __name__ == "__main__":
    # Note: Need to mock the database client and logger if not configured
    # This example focuses on the strategy logic.
    pass
    # To run:
    # asyncio.run(main())

Current state flow

Currently we are applying credits distribution and assignment in two different flows:

Endpoint POST /wunder/vehicles/process-dynamic-free-minutes

Summary: A batch processing endpoint, triggered by a scheduled job, that applies "Free Ride Minutes" to the entire active vehicle fleet. The resulting credit distribution is stored in the Supabase database.

Flow:

  • Google Cloud Scheduler configured to run twice a day:
  • 05:00 BST
  • 15:00 BST
  • Fetches the entire vehicle fleet from the Wunder API via functions.vehicles.get_all_vehicles_from_search.
  • Loads geographical data (GeoJSON) from Google Cloud Storage using utils.territories.TerritoryRepository.
  • Initializes a CompositeDistributionStrategy which combines multiple credit assignment strategies in a specific order of precedence:
    • WeightedRandomDistributionStrategy.
    • GeographicalDistributionStrategy: Camden instance.
    • GeographicalDistributionStrategy: General instance.
    • IdleDistributionStrategy.
  • An instance of CreditsAssignerVehicles is used to persist the new distribution.
  • The assign_credits method within the assigner uses the DatabaseCreditsStorage strategy to update the vehicle_credits_distribution table in Supabase.
  • Returns a 200 OK response with the newly calculated and stored credit distribution for the fleet.

Request:

Query Parameters:

Name Type Required Default Description
dry_run bool No False If True, calculates distribution but does not save to the database.

Headers:

Name Type Required Description
x-api-key string Yes The API key for authentication.

Body:
None

Response

  • Success (200 OK):
{
  "message": "OK",
  "data": [
    {
      "credits": 10,
      "strategy": "idle",
      "id": 123,
      "history_id": null,
      "vehicle_id": 54321,
      "lat": 51.52,
      "lon": -0.12,
      "created_at": "2023-10-27T10:00:00Z",
      "updated_at": "2023-10-27T10:00:00Z"
    }
  ]
}

Sequence Diagram:

sequenceDiagram
    participant C as Google Cloud Scheduler
    participant A as FastAPI Endpoint
    participant W as Wunder API
    participant GCS as Google Cloud Storage (GCS)
    participant S as Supabase DB

    C->>A: POST /wunder/vehicles/process-dynamic-free-minutes
    A->>A: Validate API Key
    A->>W: Get entire vehicle fleet
    W-->>A: Return fleet data
    A->>GCS: Fetch GeoJSON files for territories (defined as env variables)
    GCS-->>A: Return GeoJSON data
    A->>A: Compute credit distribution via CompositeStrategy
      Note over A: Steps inside CompositeStrategy:
      Note over A: 1. Apply WeightedRandomDistributionStrategy (base layer)
      Note over A: 2. Apply GeographicalDistributionStrategy (Camden)
      Note over A: 3. Apply GeographicalDistributionStrategy (General)
      Note over A: 4. Apply IdleDistributionStrategy (final adjustments for idle vehicles)
    A->>S: Assign credits (update vehicle_credits_distribution_history table)
    S-->>A: Confirm update
    A->>S: Get current distribution
    S-->>A: Return current distribution
    A-->>C: 200 OK with distribution data

Endpoint POST /wunder/vehicles/update-dynamic-free-minutes

Summary: Event-driven endpoint called by Hookdeck hook that updates the assigned credits for a single vehicle immediately after a reservation ends. This allows for near real-time adjustments to a vehicle's credit status based on its new location and state.

Flow: 1. Receives a reservationEnd event payload, which includes the carId (vehicle ID). 2. Fetches the specific vehicle's data from the Wunder API using functions.vehicles.get_vehicle to ensure it's not currently reserved. 3. Loads geographical data from Google Cloud Storage, similar to the batch processing endpoint. 4. Fetches the vehicle's current credit distribution from the vehicle_credits_distribution table in Supabase. 5. Initializes a CompositeDistributionStrategy with a different set of strategies for post-ride updates: - DowngradeDistributionStrategy: re-assign credits if certain criteria is met. - GeographicalDistributionStrategy: Assigns credits if the vehicle is in specific areas. 6. The composite strategy computes a new credit value for the vehicle based on its end-of-ride state. 7. If a change is needed, CreditsAssignerVehicles updates the vehicle's record in the vehicle_credits_distribution_history table in Supabase. 8. Returns a 200 OK response confirming the update or indicating that no update was necessary.

Request:

Headers:

Name Type Required Description
x-api-key string Yes The API key for authentication.

Body:

A BodyClassReservationEnd payload containing a reservationEnd event.

{
  "eventName": "reservationEnd",
  "data": {
    "reservationId": 12345,
    "carId": 54321,
    "userId": 987,
    "endTime": 1672531200,
    ...
  }
}

Response Model: - Success (200 OK):

{
  "message": "credits updated successfully",
  "distribution": {
    "54321": {
      "credits": 15,
      "strategy": "geo_camden",
      "vehicle_id": 54321
    }
  }
}
- No Update Needed (200 OK):
{
  "message": "No update needed"
}
- Error: Returns standard HTTP error responses (400, 401, 404, 500) with a descriptive message.

Sequence Diagram:

sequenceDiagram
    participant C as Event Source (Hookdeck)
    participant A as FastAPI Endpoint
    participant W as Wunder API
    participant GCS as Google Cloud Storage (GCS)
    participant S as Supabase DB

    C->>A: POST /wunder/vehicles/update-dynamic-free-minutes (ReservationEndEvent)
    A->>A: Validate API Key & Event
    A->>W: Get vehicle data (to check isReserved status)
    W-->>A: Return vehicle data
    A->>S: Get current credit distribution for vehicle
    S-->>A: Return current credits
    A->>GCS: Fetch GeoJSON for territories
    GCS-->>A: Return GeoJSON data
    A->>A: Compute new credit distribution via CompositeStrategy
    alt Credits changed
    A->>A: Compute credit distribution via CompositeStrategy
        Note over A: Steps inside CompositeStrategy:
        Note over A: 1. Apply DowngradeDistributionStrategy
        Note over A: 3. Apply GeographicalDistributionStrategy (Camden)
        A->>S: Assign credits (update vehicle_credits_distribution_history table)
        S-->>A: Confirm update
        A->>S: Get new current distribution
        S-->>A: Return new distribution
        A-->>C: 200 OK with new distribution
    else No change
        A-->>C: 200 OK with "No update needed"
    end


How-to

Adding/updating territories to GeographicalStrategy

The GeographicalStrategy implementaton class could be instantiated as many times as needed to apply this kind of strategy sequentialy. E.g:

  # Create the strategies
  geo_strategy = GeographicalDistributionStrategy(gdf=gdf_default)
  geo_strategy_camden = GeographicalDistributionStrategy(
      gdf=gdf_camden,
      geo_credits=GeographicalDistributionStrategy.CAMDEN_GEO_CREDITS,
      strategy_subname="camden",
  )
  composite_strategy = CompositeDistributionStrategy(
      strategies=[
          geo_strategy_camden,
          geo_strategy,
      ]
  )

By default a GeographicalStrategy strategy implementation is using the terrotories defined as GCS paths in the CAMDEN_GEOJSON_GCS_PATH environment variables.

This CAMDEN_GEOJSON_GCS_PATH would accept comma-separated strings. E.g.:

  CAMDEN_GEOJSON_GCS_PATH=geojson/boroughs/boroughs_CM.geojson,geojson/boroughs/boroughs_HS.geojson