Written by James Britt
A customer asks about a charter flight for Friday. Later, they change the departure airport, add another passenger and ask whether the original quote still applies. The team now has several emails describing different versions of the same trip.
That is a useful Ruby project: an application that keeps the request, its revisions and the latest quote together. It gives developers a reason to work with relational data, explicit state changes, deadlines and background jobs.
A public charter website such as Hera Flight can help frame the business setting. The project below is an independent implementation idea, not a description of that company’s software. Its purpose is to organise enquiries and commercial follow-up; flight approval stays with the people responsible for the operation.

Define the records before building the dashboard
Start with a Rails application backed by a relational database. PostgreSQL would be a reasonable choice for this example.
The central record is an Enquiry. It belongs to an account, identifies the customer contact and has an assigned staff member. Keep the actual journey in associated TripLeg records so a return journey does not require fields such as return_airport_2 later.
A first version might contain:
Enquiry: customer request, owner and overall workflow status.TripLeg: departure, destination, requested time and passenger count.Quote: the commercial offer associated with an enquiry.QuoteVersion: a preserved revision of that offer.FollowUp: a task or reminder with an owner and deadline.ActivityEvent: a record of important changes and who made them.
These are proposed application models, not built-in Rails components.
Resolve airport selections against a maintained reference table. A city name can represent several possible departure points. A string that looks like an airport code is not enough to establish that the selection is valid.
Use Active Record validations to give useful feedback, then add database constraints for required relationships and uniqueness rules. A model-level uniqueness check alone does not prevent concurrent duplicate inserts.
Keep requested details separate from confirmed details
The first enquiry should describe what the customer wants. It should not silently become a confirmed itinerary when a staff member adds a quote.
Use status names that describe actual events: new, under_review, awaiting_customer and closed, for example. Define what each transition requires.
A Ruby service object such as Enquiries::ReviseTrip could update the requested legs and record an activity event in one transaction. It should also flag existing quotes for review if their underlying trip details have changed.
Do not leave that responsibility to a developer remembering to update several unrelated fields in a controller. Put the rule in one place and exercise it in tests.
For the customer interface, distinguish “request received” from “booking confirmed.” The application should display the status the team has actually recorded.
Write the deadline rule in plain Ruby
Quote-expiry logic is small enough to develop without Rails. That makes it easier to inspect and test before it is attached to a scheduled job.
The following method returns IDs for sent quotes that expire within a specified window:
QuoteSnapshot = Struct.new(
:id, :state, :expires_at,
keyword_init: true
)
def expiring_quote_ids(quotes, now:, within_seconds: 3600)
unless within_seconds.is_a?(Integer) && within_seconds >= 0
raise ArgumentError, "within_seconds must be a non-negative integer"
end
deadline = now + within_seconds
quotes.select do |quote|
quote.state == :sent &&
quote.expires_at &&
quote.expires_at > now &&
quote.expires_at <= deadline
end.sort_by do |quote|
[quote.expires_at, quote.id]
end.map(&:id)
end
The input contract is deliberately narrow. Each snapshot has a unique integer ID, a symbolic state and either a Ruby Time value or nil for its deadline. The caller supplies now as a Time value too.
The lower boundary is exclusive: a quote expiring exactly at now has already expired. The upper boundary is inclusive. A zero-length window returns no results.
Sorting by expiry puts the most urgent records first, with the ID making ties predictable. The method does not send a message or change a record. It identifies candidates for the next step.
A test can pass a fixed Time.utc value and verify the result without waiting for the clock. When Active Record supplies string status values, translate them through an explicit mapping before constructing the snapshots.
For a large production dataset, apply the same conditions in a scoped database query rather than loading every quote into Ruby. Keep tests showing that the database selection and the plain Ruby rule agree at the boundaries.
Handle local departure times explicitly
A request for “09:00 on Friday” is incomplete without a date and a time zone.
For each trip leg, retain the customer’s requested local date and time, the associated named time zone and the resolved UTC instant. Show the local meaning back to the user before accepting the request.
Do not use a fixed offset as a substitute for a named zone. Daylight-saving changes can make some local times ambiguous or nonexistent. Rails provides time-zone support through Active Support, but the application still needs a policy for those cases.
Avoid silently shifting a customer’s requested departure time. Ask for clarification when the input cannot identify one instant.
Quote expiry is a separate deadline. Store it consistently and label its display zone. If it changes, record a new revision and make sure pending reminders refer to the current deadline.
Preserve quote versions instead of overwriting them
A quote can change because the itinerary changed, an option was replaced or a commercial term was revised. Overwriting the old amount leaves the team unable to explain what the customer saw earlier.
Save a version containing the quoted amount, currency, included items, exclusions, expiry and the trip revision it covers. Once issued, that version should remain available as a historical record.
Represent monetary amounts using an exact decimal representation or integer minor units with a defined currency scale. Avoid binary floating-point values for amounts that need exact arithmetic.
When the customer accepts, record the version they accepted. Check that it is still current and valid inside the transaction that changes the acceptance state.
Rails row locking can help coordinate competing updates. For example, lock the parent quote before checking its current version and recording acceptance. Issuing a replacement version must follow the same locking protocol; locking only one side does not coordinate the whole workflow.
Customer acceptance can be a distinct state from operational confirmation. The software should preserve that distinction.
Use Active Job for reminders, with a final check before sending
Active Job gives Rails a common interface for background work. Choose and configure a durable queue backend, then decide how recurring reminder checks will be scheduled.
A job should reload the quote and check its current state when it runs. It may have been accepted, withdrawn or revised since the reminder was queued.
Store a reminder key that includes the quote version and reminder type, and enforce uniqueness in the database. That helps prevent two scheduler runs from creating the same logical reminder.
It does not guarantee exactly one email. A worker can send a message and crash before recording success. If the delivery provider supports an appropriate idempotency mechanism, use it. Otherwise, retain delivery attempts and a way to investigate uncertain outcomes.
Also handle the gap between saving a quote change and submitting work to the queue. A pending notification record written in the same database transaction can give a recovery process something durable to find if enqueueing fails.
The useful result is a follow-up system the team can investigate when something goes wrong.
Keep account access in the Rails request path
The dashboard will contain customer contacts and proposed itineraries. Every record lookup needs to respect the signed-in user’s account and permissions.
Load an enquiry through the authorised account scope, then check the requested action. A staff member allowed to view an enquiry might not be allowed to issue a revised quote.
Strong parameters control which attributes a Rails action accepts. They do not establish authorization. Filtering a form field or hiding a button is not sufficient protection.
Treat exported PDFs and attachments as part of the same access model. Check who can retrieve them and whether a copied download link grants more access than intended.
Review custom logs as well as Rails parameter filters. Request notes and customer details can leak through debug statements or external monitoring even when the normal form parameters are filtered.
Test revisions and timing before polishing the interface
Minitest or RSpec can cover the expiry method without booting the Rails application. Test an expired quote, one expiring exactly at the window’s end, a missing deadline and a withdrawn quote.
The integration tests should follow the workflow:
- Changing the requested trip flags the existing offer for review.
- Accepting an old quote version fails when a newer version is current.
- Repeating an acceptance request does not create duplicate activity or notifications.
- A queued reminder skips a quote that has since been withdrawn.
- A user cannot retrieve another account’s enquiry by changing an ID.
Use synthetic customer records and stub external delivery services in the test environment.
The first release can stay modest: an enquiry form, an assigned work queue, preserved quotes and reliable follow-up. That is enough to make the project useful while giving Ruby developers substantial work in modelling, time handling, transactions and background processing.
