/mileage — Slash Command Spec¶
Phase 2. Implemented (manual mileage entry, PDF receipt generation, auto-estimated mileage via OpenRouteService).
Commands¶
/mileage request
/mileage list
/mileage request¶
Signature¶
/mileage request
Access¶
Organizer+
Purpose¶
Self-service mileage reimbursement request. Walks the organizer through a short prompted flow and writes a new row to the Reimbursements Grist table.
Flow¶
/mileage requesttakes all fields as native slash-command parameters (no modal — Discord modals cap out at 5 fields, and this flow has up to 7):start_zip— organizer's origin ZIP code (text)cashapp— required; used for payout trackingtotal_miles— optional. If omitted and a route can be auto-estimated (see Mileage Auto-Estimation below), the estimate is used directly; otherwise requiredpit_destination— optional. Autocomplete from$Event_locations.nickname, shared with/event new's location cache (EventCog._get_locations(), 300s TTL, invalidated by theEvent_locationsGrist webhook). If run from an event's organizing thread and that event hasEvents.locationset, auto-filled from there instead; otherwise required.event_description— optional. If run from an event's organizing thread, auto-detected and auto-filled from thatEventsrow (see Event Auto-Detection below); otherwise required free text (e.g. "South P1 Jun 14 2025")event_date— optional. Auto-filled the same way when run from an organizing thread; otherwise required, parsed with the samedateutil-based parser used by/event new(e.g.6/14,June 14,2025-06-14)end_zip— optional; if omitted, the confirmation shows a round trip back tostart_zip. Not persisted — display-only, matchingtrip_end's formula stub.other_costs/other_cost_description— optional pair for tolls, ferry, etc. Must be given together (both or neither) or the command errors out before any Grist lookups happen.
Event auto-detection: the bot checks whether interaction.channel matches an Events row's organizer_thread or Discord_event URL (utils/event_resolver.resolve_from_thread, the same mechanism /event attendance commands use). If it does, validated_event is set on the created row automatically — this always happens when in a linked thread, regardless of whether event_description/event_date were also filled in manually. event_description, event_date, and pit_destination are only required as explicit parameters when the command is run outside a linked thread, or when the linked event doesn't have that data available (e.g. no Events.location set) — in the no-thread case they exist so an admin reviewing the row later has enough context to manually set validated_event themselves in Grist.
-
The bot shows a confirmation as an ephemeral message with Confirm/Cancel buttons (plus a third "Use estimate" button when a manually-entered
total_milesdiverges from the auto-estimate by more thanconfig.MILEAGE_ESTIMATE_MARGIN): ``` 🛣️ Mileage reimbursement — confirmRoute: 98101 → South Pit → 98101 (round trip) Miles: 47.2 Rate: $0.25/mi Total: $11.80
Event: South P1 Jun 14 2025 Cashapp: $alice
💡 We estimated ~19.8 mi for this route — tap "Use estimate" to switch.
[Confirm] [Use estimate] [Cancel]
``
Withother_costs/other_cost_descriptiongiven, an extra line appears betweenRateandTotal, e.g.Other costs: $12.00 (ferry toll), andTotal` includes it.
Rate is fetched from Global.mileage_rate at request time (see Grist Table: Global below) and Total is computed locally (total_miles * rate + other_costs) for preview purposes only — the value persisted on the row (total_reimbursement) is computed independently by the Grist formula (see below), which uses the same mileage_rate value written onto that row. The suggestion line is framed as a convenience, never a warning, and never blocks Confirm — clicking "Use estimate" swaps the displayed/submitted mileage in place; Confirm always submits whatever total is currently shown.
- On confirm: writes new
Reimbursementsrow:
| Field | Value |
|---|---|
payee |
$People.common_handle of caller |
validated_payee |
Reference to caller's $People row |
validated_event |
Reference to the linked Events row, if run from that event's organizing thread (else not written) |
event_description |
Auto-filled from the linked event's concat_title (falling back to structured_description/other_description), or from input if not in a linked thread |
event_date |
Auto-filled from the linked event's event_date, or from input if not in a linked thread |
trip_start |
start ZIP |
event_location |
$Event_locations.nickname of selected location |
total_miles |
whatever the requester entered or confirmed — the auto-estimate, if used, or a manual override; never silently substituted |
cashapp |
from input (required) |
mileage_rate |
Fetched from Global.mileage_rate at request time (see below) |
other_costs / other_cost_description |
Written only when both are given (validated as both-or-neither) |
invoice_id, submission_datetime, and total_reimbursement are formula-driven by Grist defaults — bot does not write these. trip_end is a formula stub in Grist that returns "" — bot does not write it; end ZIP is used for the confirmation preview only and not persisted.
- Ephemeral confirmation with
invoice_idfor reference (plain text, no attachment — the receipt does not go back to the requester). A PDF receipt is generated (utils/mileage_receipt.generate_mileage_receipt), uploaded toReimbursements.attachmentson the row, and attached to a notification posted to#records-workstream(same channel/donationuses) so Central Committee/treasurer has it when reviewing and markingpaid_to_payee. Receipt generation/upload failures are logged as warnings and do not block the reimbursement submission, which has already succeeded by that point.
Grist Table: Event_locations¶
Fields used by the bot:
| Field | Type | Notes |
|---|---|---|
nickname |
Text | Short name shown in autocomplete |
location_string |
Text | Human-readable address label |
lat |
Numeric | Decimal degrees — used directly for routing |
lng |
Numeric | Decimal degrees — used directly for routing |
region |
Text | Optional — may be used to filter by region |
Grist Table: Global¶
Single-row table of static config values. Fields used by the bot:
| Field | Type | Notes |
|---|---|---|
mileage_rate |
Numeric | Dollars-per-mile reimbursement rate — fetched via MileageCog._get_mileage_rate() at the start of every /mileage request and written onto the new Reimbursements row (see below), rather than hardcoded in the bot |
Grist Table: Reimbursements¶
Relevant writable fields (bot fills these):
| Field | Type | Notes |
|---|---|---|
payee |
Text | Common handle — not auto-linked; human-readable |
validated_payee |
Reference("People") | Resolved from caller's Discord snowflake |
validated_event |
Reference("Events") | Auto-linked when run from that event's organizing thread; otherwise left unset |
event_description |
Text | Auto-filled from the linked event, or free text from organizer |
event_date |
Date | Auto-filled from the linked event, or from organizer input |
trip_start |
Text | Start ZIP |
event_location |
Text | $Event_locations.nickname of selected location |
total_miles |
Numeric | Whatever the requester entered or confirmed (auto-estimate or manual override) |
cashapp |
Text | Required payout handle |
mileage_rate |
Numeric | Copied from Global.mileage_rate at request time |
other_costs |
Numeric | Optional — tolls, ferry, etc. Written only alongside other_cost_description |
other_cost_description |
Text | Required if other_costs is given, otherwise left unset |
attachments |
Attachments | Generated PDF receipt, uploaded via GristClient.upload_attachment after the row is created |
Formula-driven fields (Grist computes, bot does not write):
| Field | Notes |
|---|---|
invoice_id |
Auto-generated 6-char UUID fragment |
submission_datetime |
Defaults to NOW() |
total_reimbursement |
total_miles * mileage_rate + other_costs |
trip_end |
Formula stub — returns ""; end ZIP used for the confirmation preview only, not persisted |
Dev Notes¶
resolve_member(interaction.user.id, grist)(utils/member_resolver.py) resolves the caller's$Peoplerow — required forvalidated_payeeresolve_from_thread(interaction, grist, show_links=False)(utils/event_resolver.py) resolves theEventsrow frominteraction.channel, matchingorganizer_thread/Discord_eventURLs — the same helper/event attendancecommands use to auto-detect event context.EventNotFoundis treated as "not in a linked thread," not an error — falls back to requiring manualevent_description/event_date.$Event_locationsautocomplete reusesEventCog._get_locations()(cogs/event.py), a 300s-TTL cache shared with/event new's location autocomplete and invalidated by theEvent_locationsGrist webhook — no separate cache maintained incogs/mileage.pypit_destinationauto-fills fromEvents.location(Reference(Event_locations), set by/event new— seedocs/event.md) when the linked event has one; falls back to the manual autocomplete param when unset or not in a linked thread- Mileage auto-estimation: see the dedicated section below
- PDF receipt (
utils/mileage_receipt.py,reportlab) is generated from the freshly created row's fields (invoice_id/total_reimbursement/event_description/event_date/event_location/total_miles— in the ACL column group the bot can Read) plus the payee'sPeoplefields for the display handle.trip_start/cashapp/mileage_rate/other_costs/other_cost_description/submission_datetimeare not read back from the row — the bot's Grist ACL denies Read ontrip_start/cashappby design, andmileage_rate/other_costs/other_cost_descriptionaren't depended on for Read either (a prior ACL gap meantother_costs/other_cost_descriptioncame back missing from a freshly created row despite writing successfully, even after being granted Create) — so_generate_and_attach_receiptoverlays the original request'sstart_zip/cashapp/mileage_rate/other_costs/other_cost_description, and (ifsubmission_datetimeisn't readable) the current time, onto the row's fields before rendering.other_costs, when present, renders as a second line item in the PDF (description fromother_cost_description). Uploaded viaGristClient.upload_attachment(POST /api/docs/{docId}/attachments, multipartuploadfield, returns attachment IDs) and written toReimbursements.attachmentswithgrist_list(attachment_id)
/mileage list¶
Signature¶
/mileage list
Access¶
Any organizer (shows own records only)
Purpose¶
Lists the caller's own Reimbursements rows, most recent first. Read-only; ephemeral.
Response Format¶
🛣️ Your reimbursements
#abc123 Jun 14 South P1 47.2 mi $11.80 ⏳ pending
#def456 May 1 North P2 62.0 mi $15.50 ✅ paid
paid_to_payee field drives the status indicator.
Dev Notes¶
- Filter:
Reimbursements.lookupRecords(validated_payee=caller_people_id, order_by="-submission_datetime") - No writes — read-only
Mileage Auto-Estimation¶
utils/mileage_distance.py computes a round-trip driving-distance estimate for /mileage request, used to pre-fill total_miles when omitted and to sanity-check it when supplied manually.
How it works:
1. Geocode start_zip (and end_zip, if given and different from start_zip) to lat/lng via pgeocode.Nominatim("us") — an offline, GeoNames-derived ZIP centroid lookup. No API key, no network call per-request (data cached locally after first use).
2. Call OpenRouteService's directions API (api.heigit.org/openrouteservice/v2/directions/driving-car — not the deprecated api.openrouteservice.org, which shuts down 2026-08-24) for the start→pit leg (and pit→end leg, if end_zip differs), using the pit's Event_locations.lat/lng. One-way distance is doubled for the default round-trip case. Requests pass radiuses (config.ORS_SNAP_RADIUS_METERS, default 5000) to widen ORS's own point-snapping search past its 350m default — some ZIP centroids land off-road (rural areas, parks, water), and the tight default radius otherwise fails with a "no routable point" error on an otherwise-valid coordinate.
3. Any failure — no lat/lng on the selected location, an unresolvable ZIP, ORS_API_KEY unset, no routable point found even within the widened radius, or an ORS timeout/error — returns DistanceEstimate(miles=None, reason=...) and the command falls back to fully manual entry, with no user-facing error beyond "total_miles is required" if it was also omitted. Failures are logged (pssbot.mileage_distance) but never raised.
Margin check (config.MILEAGE_ESTIMATE_MARGIN, default 20%): when both a manual total_miles and a computed estimate exist and they differ by more than the margin, this is surfaced two different ways depending on audience — deliberately asymmetric:
- Confirmation UI (requester-facing, ephemeral): a suggestion, never a warning — a "💡 We estimated ~N mi" line plus a "Use estimate" button. Never blocks Confirm. This is framed as a convenience, not a validation gate.
- #records-workstream notification (treasurer/Central Committee-facing): the one place a discrepancy is surfaced for a human double-check, since it's the existing review checkpoint before paid_to_payee is set, not a new gate. Shows both numbers only when they diverge; otherwise unchanged from today.
What's persisted / in the PDF: always what the requester entered or confirmed — the computed estimate is never silently substituted as the value of record, whether it was used directly (no manual entry given) or overridden.
Config: ORS_API_KEY (optional — empty disables auto-estimation entirely, no bot startup failure), ORS_TIMEOUT_SECONDS (default 5), MILEAGE_ESTIMATE_MARGIN (default 0.20). ORS_API_KEY is provisioned under the shared contact@pugetsoundsra.org account, not an individual's, and lives only in the untracked .env.prod/.env.test files — never in .env.example or committed anywhere.
Open Questions¶
- ~~OSM routing~~ — resolved: implemented via OpenRouteService, see Mileage Auto-Estimation above.
- ~~
$Event_locationsrouting fields~~ — resolved:lat/lngfields are now read and used for auto-estimation. - ~~Mileage rate~~ — resolved: sourced from
Global.mileage_rateat request time and written onto the newReimbursementsrow, so there's a single place to change it (no moreconfig.MILEAGE_RATE/Grist-formula duplication). - ~~PDF receipt~~ — resolved: implemented via
reportlab, styled after the chapter's existing manual mileage invoice template (Pay To/Bill To, itemized mileage line, boxed total). Goes toReimbursements.attachmentsand#records-workstream, not to the requester.
Future Work¶
Not implemented — documented direction only:
- Reduce/eliminate needing to open Grist for reimbursement processing. Currently the treasurer/reimburser must go into Grist directly to mark
paid_to_payee/OC_submitted/OC_paidand to readcashappfor payout. A Discord-native equivalent (e.g. a Central-Committee-gated/mileage mark-paid [invoice_id]command) would close that loop entirely from Discord. - Auto-fill
cashappfrom the payee's profile instead of requiring manual entry on every/mileage request— e.g. reading it from the payee's Discord profile or aPeoplefield, if one exists or is added, rather than a free-text prompt each time. - The editor account's Grist ACL already has Update granted on
paid_to_payee/OC_submitted/OC_paid/attachments(provisioned ahead of this work alongside the PDF receipt'sattachmentsaccess), so a follow-up PR implementing/mileage mark-paidwon't need an ACL change first. /mileage regenerate-receipt [invoice_id]— re-render and re-upload the PDF receipt from aReimbursementsrow after a manual correction in Grist (e.g. treasurer fixestotal_milespost-submission). Same shape/access-gating as the/mileage mark-paididea above; not designed yet.- More flexible
start_zip/end_zipinput. Currently ZIP-only, geocoded viapgeocode's offline ZIP-centroid lookup — a city name, address, or other free-text location isn't recognized and just fails geocoding silently (clean fallback to manual entry, no explicit "that's not a ZIP" error). If broader input is wanted later, ORS's own geocoding endpoint (api.heigit.org/pelias/v1) accepts free-text place/address queries and could replace thepgeocodestep — a separate, larger change than this plan's ZIP-only scope. - More flexible
pit_destinationinput. Currently the destination side of auto-estimation only works whenpit_destinationresolves to a known$Event_locationsrow withlat/lngset (via nickname match or the linked event'sEvents.location) — a location that doesn't map to a logged pit gets no auto-estimate, same as today's manual-only flow. Supporting arbitrary destinations (e.g. geocoding a free-text address/URL instead of requiring a pre-loggedEvent_locationsrow) is a larger change, not scoped here.