Skip to content

/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

  1. /mileage request takes all fields as native slash-command parameters (no modal — Discord modals cap out at 5 fields, and this flow has up to 7):
  2. start_zip — organizer's origin ZIP code (text)
  3. cashapp — required; used for payout tracking
  4. total_miles — optional. If omitted and a route can be auto-estimated (see Mileage Auto-Estimation below), the estimate is used directly; otherwise required
  5. pit_destination — optional. Autocomplete from $Event_locations.nickname, shared with /event new's location cache (EventCog._get_locations(), 300s TTL, invalidated by the Event_locations Grist webhook). If run from an event's organizing thread and that event has Events.location set, auto-filled from there instead; otherwise required.
  6. event_description — optional. If run from an event's organizing thread, auto-detected and auto-filled from that Events row (see Event Auto-Detection below); otherwise required free text (e.g. "South P1 Jun 14 2025")
  7. event_date — optional. Auto-filled the same way when run from an organizing thread; otherwise required, parsed with the same dateutil-based parser used by /event new (e.g. 6/14, June 14, 2025-06-14)
  8. end_zip — optional; if omitted, the confirmation shows a round trip back to start_zip. Not persisted — display-only, matching trip_end's formula stub.
  9. 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.

  1. The bot shows a confirmation as an ephemeral message with Confirm/Cancel buttons (plus a third "Use estimate" button when a manually-entered total_miles diverges from the auto-estimate by more than config.MILEAGE_ESTIMATE_MARGIN): ``` 🛣️ Mileage reimbursement — confirm

    Route: 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.

  1. On confirm: writes new Reimbursements row:
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.

  1. Ephemeral confirmation with invoice_id for 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 to Reimbursements.attachments on the row, and attached to a notification posted to #records-workstream (same channel /donation uses) so Central Committee/treasurer has it when reviewing and marking paid_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 $People row — required for validated_payee
  • resolve_from_thread(interaction, grist, show_links=False) (utils/event_resolver.py) resolves the Events row from interaction.channel, matching organizer_thread/Discord_event URLs — the same helper /event attendance commands use to auto-detect event context. EventNotFound is treated as "not in a linked thread," not an error — falls back to requiring manual event_description/event_date.
  • $Event_locations autocomplete reuses EventCog._get_locations() (cogs/event.py), a 300s-TTL cache shared with /event new's location autocomplete and invalidated by the Event_locations Grist webhook — no separate cache maintained in cogs/mileage.py
  • pit_destination auto-fills from Events.location (Reference(Event_locations), set by /event new — see docs/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's People fields for the display handle. trip_start/cashapp/mileage_rate/other_costs/other_cost_description/submission_datetime are not read back from the row — the bot's Grist ACL denies Read on trip_start/cashapp by design, and mileage_rate/other_costs/other_cost_description aren't depended on for Read either (a prior ACL gap meant other_costs/other_cost_description came back missing from a freshly created row despite writing successfully, even after being granted Create) — so _generate_and_attach_receipt overlays the original request's start_zip/cashapp/mileage_rate/other_costs/other_cost_description, and (if submission_datetime isn'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 from other_cost_description). Uploaded via GristClient.upload_attachment (POST /api/docs/{docId}/attachments, multipart upload field, returns attachment IDs) and written to Reimbursements.attachments with grist_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

  1. ~~OSM routing~~ — resolved: implemented via OpenRouteService, see Mileage Auto-Estimation above.
  2. ~~$Event_locations routing fields~~ — resolved: lat/lng fields are now read and used for auto-estimation.
  3. ~~Mileage rate~~ — resolved: sourced from Global.mileage_rate at request time and written onto the new Reimbursements row, so there's a single place to change it (no more config.MILEAGE_RATE/Grist-formula duplication).
  4. ~~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 to Reimbursements.attachments and #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_paid and to read cashapp for 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 cashapp from the payee's profile instead of requiring manual entry on every /mileage request — e.g. reading it from the payee's Discord profile or a People field, 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's attachments access), so a follow-up PR implementing /mileage mark-paid won't need an ACL change first.
  • /mileage regenerate-receipt [invoice_id] — re-render and re-upload the PDF receipt from a Reimbursements row after a manual correction in Grist (e.g. treasurer fixes total_miles post-submission). Same shape/access-gating as the /mileage mark-paid idea above; not designed yet.
  • More flexible start_zip/end_zip input. Currently ZIP-only, geocoded via pgeocode'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 the pgeocode step — a separate, larger change than this plan's ZIP-only scope.
  • More flexible pit_destination input. Currently the destination side of auto-estimation only works when pit_destination resolves to a known $Event_locations row with lat/lng set (via nickname match or the linked event's Events.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-logged Event_locations row) is a larger change, not scoped here.