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:
-
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 datavehicle_credits_distribution-> for last assignments. (populated by a trigger on creation fromvehicle_credits_distribution_history)
- DatabaseCreditsStorage: The concrete implementation that saves the distribution to a database table (
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:
-
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:
- WeightedRandomDistributionStrategy
- GeographicalDistributionStrategy (for a specific "Camden" zone, assigns 10 credits)
- 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:
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:
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:
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:
- Scheduled fleet distribution ->
POST /wunder/vehicles/process-dynamic-free-minutes - ReservationEnd webhook event processing ->
POST /wunder/vehicles/update-dynamic-free-minutes
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
CompositeDistributionStrategywhich combines multiple credit assignment strategies in a specific order of precedence:WeightedRandomDistributionStrategy.GeographicalDistributionStrategy: Camden instance.GeographicalDistributionStrategy: General instance.IdleDistributionStrategy.
- An instance of
CreditsAssignerVehiclesis used to persist the new distribution. - The
assign_creditsmethod within the assigner uses theDatabaseCreditsStoragestrategy to update thevehicle_credits_distributiontable in Supabase. - Returns a
200 OKresponse 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
}
}
}
200 OK): - 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.: