Turning Solidus into a full stack car dealership and rental platform
TL;DR
Difficulty: Advanced
- A used car is a Solidus product with one variant and a count of exactly one at its branch, backorders off. Each branch is a stock location.
- Facts about the car are properties, choices are option types, and anything you filter by number (mileage) needs a real column added through an override.
- Rental availability is a calendar, not a stock count: a PostgreSQL daterange with an exclusion constraint stops double bookings in the database itself.
- Rental prices come from a custom price selector that reads the dates, and a deposit is a card authorisation captured in part or voided on return.
- UK rules to design for: 14 day cancellation on cars sold at a distance, no cancellation right on fixed date rentals, FCA authorisation for finance, and the VAT margin scheme.
A car is one of a kind, costs thousands, is paid for with deposits and finance, and if you rent it out, it is booked by date rather than sold from a shelf. This article shows how each of those awkward facts maps onto Solidus, step by step, using an invented dealership with branches in Leeds and Manchester, and links to the real dealership platform I built on it.
A car is an awkward thing to sell through a shop
Most online shops sell things in boxes. Twenty identical mugs sit on a shelf, a customer buys two, 18 remain, and a parcel goes out. Every eCommerce platform is built around that picture.
A car breaks almost every part of it. A used car is one of a kind. It is worth thousands of pounds, so people pay a deposit first, and many pay through finance. It sits at one branch and is collected or driven to the buyer. If the same business also rents cars out, the question is no longer "how many are in stock" but "is this car free from Friday to Monday". And around every sale and every rental sits paperwork: a licence check, a contract, a logbook.
Solidus is an open source eCommerce platform written in Ruby on Rails.1 It suits this kind of business because almost every piece of it can be replaced or extended in plain Ruby. Below, each awkward part of a dealership maps onto Solidus in turn, ending with the UK rules that shape the build.
I have built this for real. The auto dealership platform case study describes it: one Ruby on Rails and Solidus platform, live behind Auto Prammer, that runs car sales, rentals, the garage, vouchers and marketing from one admin. Everything below is the general method, explained with an invented dealership, not the client's set up.
Worked example: an invented dealership in Leeds
Kirkstall Motors is invented. Hannah runs it from two branches, one in Leeds and one in Manchester. She sells used cars and rents out a small fleet of hatchbacks, and her shop lives at kirkstallmotors.example.
Three jobs have to run on one system:
- Selling used cars, each one unique, priced between about £8,000 and £25,000.
- Renting fleet cars by the day, collected and returned at a branch.
- Holding a security deposit on every rental, and releasing it, or part of it, when the car comes back.
A used car is a product with exactly one unit
Solidus describes what you sell in two layers. A product is the thing on the page: its name, description and images. A variant is the version a customer can actually buy, with its own price, its own stock and its own SKU (Stock Keeping Unit, the internal reference code). Every product has a master variant, and can have more variants when there are choices to make.2
Stock lives one level lower. For every variant and every stock location there is a stock item, and the stock item holds the count on hand. Solidus refuses a negative count unless the stock item is marked as backorderable, and it changes the count under a database lock, so two checkouts cannot both take the last unit.3 When inventory tracking is on, Solidus only treats a variant as in stock if one of its stock items has a count above zero.4
For a used car, this means: one product per car, one master variant, a count of exactly one at the branch where the car stands, and backorders switched off. Selling the car takes the count to zero, and it drops out of everything that lists available stock.
Worked example: the blue Golf in Leeds
Hannah buys a 2021 Volkswagen Golf and puts it on the forecourt in Leeds.
- She creates a product called "2021 Volkswagen Golf Life 1.5 TSI, blue", priced at £16,495.
- Solidus creates the master variant with it. She gives it the SKU
KM-0412. - At the Leeds stock location the stock item gets a count on hand of 1, backorderable off. At Manchester the count is 0.
- A buyer pays the deposit online. Checkout completes and the Leeds count goes from 1 to 0.
- A second buyer opens the same page a minute later. The variant now has no stock item above zero, so the car no longer counts as in stock, and there is no unit left to allocate to a second order.
Nothing here needed custom code. The stock model already understands "one of one", as long as backorders stay off.
Properties describe the car, option types describe a choice
An option type is a choice the customer makes that creates separate variants. The Solidus source describes it with the classic example: an option type such as size, with values such as small, medium and large.5 Each combination of values becomes its own variant, with its own price and stock.
A property is a plain fact about the product: a name and a value, such as "Fuel: petrol". It creates nothing. It is shown on the page. Products also sit in taxons, which are categories, such as "Make, then Volkswagen" or "Body style, then hatchback".2
So for a used car there is usually nothing to choose, and therefore no option types. The mileage, fuel, gearbox and colour are properties. The make and the body style are taxons, which gives the shop its category pages and filters.
Rental is where option types earn their keep. A customer does not rent "the Yaris with fleet number 7". They rent "a small automatic", and they choose how many miles they want.
Worked example: the small automatic rental
Hannah sets up her rental range like this:
- One product, "Small automatic hatchback", in the taxon "Rental, then Small".
- One option type, "Mileage", with two values: "100 miles a day" and "Unlimited".
- That gives two variants. The 100 miles variant has a base rate of £42 a day. The unlimited variant has a base rate of £51 a day.
- Properties on the product: "Seats: 5", "Gearbox: automatic", "Fuel: hybrid".
Once the customer picks a mileage value, Solidus knows which variant, and which base rate, applies. The fleet cars behind it live in the rental calendar.
Vehicle data that you filter by needs real columns
Properties have one weakness. Their values are text. That is fine for "Fuel: petrol". It goes wrong as soon as you sort or filter by a number.
Text is compared character by character, from the left. So the text "9,800" counts as larger than "40,000", because the character 9 comes after the character 4. A filter for "under 40,000 miles" built on property text would hide a car with 9,800 miles on the clock, which is exactly the car a buyer wants to see.
The fix is to give the numbers you filter by real database columns. In Solidus, the recommended way to change a core class such as the product is an override: a small module that you prepend to the class, so your code runs first and can add to it.6 Put the vehicle data in its own table and attach it to the product through an override. The core tables stay untouched, which keeps upgrades simpler.
Worked example: a vehicles table for Kirkstall Motors
The migration creates one row per car, linked to its product:
create_table :kirkstall_vehicles do |t|
t.references :spree_product, null: false, foreign_key: true, index: { unique: true }
t.string :vin, null: false
t.date :first_registered_on, null: false
t.integer :mileage, null: false
t.string :fuel, null: false
t.string :transmission, null: false
t.timestamps
end
The override gives every product its vehicle:
module Kirkstall
module Spree
module Product
module AddVehicle
def self.prepended(base)
base.has_one :vehicle, class_name: "Kirkstall::Vehicle", foreign_key: :spree_product_id
end
::Spree::Product.prepend self
end
end
end
end
Now the filter is a number comparison:
Spree::Product.joins(:vehicle).where(kirkstall_vehicles: { mileage: ..40_000 })
Take three cars with 9,800, 31,200 and 52,000 miles. As text, a "less than 40,000" filter keeps only "31,200". As integers, it keeps 9,800 and 31,200, which is correct. The 9,800 mile car, the one a buyer most wants, only shows up with the real column.
Each branch is a stock location
A stock location in Solidus is a named place that stock is supplied from, with its own address. Each one has its own stock items, and shipping methods are linked to stock locations.7 For Kirkstall Motors, the two branches are the two stock locations.
When an order moves to the delivery step, Solidus runs a stock coordinator. It filters the stock locations (by default it keeps only the active ones), sorts them, allocates the units, builds packages and then works out shipping rates for each package from the shipping methods available.8 For a dealership, a "shipping method" mostly means "how does the buyer get the car": collection from the branch, or delivery by a driver.
Worked example: a Manchester buyer wants the Leeds Golf
Before the sale goes through, a buyer in Manchester asks about the blue Golf.
- The Golf's stock item in Leeds holds 1. In Manchester it holds 0.
- Hannah offers two shipping methods. "Collect from the branch" costs £0 and is linked to both branches. "Delivery by our driver within 50 miles" costs £149 and is also linked to both. Both prices are this example's assumptions.
- The buyer prefers to collect in Manchester. Hannah's team drives the car over.
- In the admin, they reduce the Leeds count by one and raise the Manchester count by one. Leeds now shows 0 and Manchester shows 1.
- The buyer checks out. The coordinator finds the unit in Manchester, and the "Collect from the branch" rate is £0.
Order, stock and collection point now agree, with no spreadsheet of which car stands where.
Rental availability is a calendar, not a stock count
A stock count answers "how many can I sell now". A rental needs to answer "is this particular car free between two dates", and the same car is free again the day it comes back. Solidus does not have a booking calendar, so this is the part you build.
The cleanest place for the rule is the database itself. PostgreSQL has range types, such as daterange, and exclusion constraints. An exclusion constraint can say "no two rows may have overlapping ranges", and with the btree_gist extension it can add "for the same car".9 Rails supports both in migrations: a daterange column and an exclusion_constraint on the table.10
One detail makes this work for rentals. A daterange includes its first day and excludes its last day. PostgreSQL writes this as [).9 So a booking from 14 to 17 August blocks the nights of the 14th, 15th and 16th, and a new booking may start on the 17th. Back to back rentals are allowed, overlapping ones are refused, and the refusal comes from the database even if two people press "book" in the same second.
The flow runs from the customer's choice to one of two outcomes: the dates are held on a specific car, or every car in the class is taken. The decision is the important box. The check is the insert itself, not a query beforehand that another customer could beat by a second.
Worked example: fleet car 7 in August
Fleet car 7 is a small automatic in Leeds. The bookings table has a car reference and a daterange called period:
enable_extension "btree_gist"
create_table :kirkstall_bookings do |t|
t.references :kirkstall_fleet_car, null: false, foreign_key: true
t.daterange :period, null: false
t.exclusion_constraint "kirkstall_fleet_car_id WITH =, period WITH &&", using: :gist
t.timestamps
end
Three customers try to book it.
- Booking A, collect Friday 14 August 2026, return Monday 17 August. Stored as
[2026-08-14, 2026-08-17). Accepted. - Booking B, Sunday 16 August to Tuesday 18 August. Stored as
[2026-08-16, 2026-08-18). The 16th is inside booking A, so the ranges overlap and PostgreSQL rejects the row. The system moves on and tries fleet car 8. - Booking C, Monday 17 August to Wednesday 19 August. Stored as
[2026-08-17, 2026-08-19). Booking A excludes the 17th, so there is no overlap. Accepted, and car 7 goes straight back out.
For a cleaning day between rentals, store each blocked period one day longer than the rental.
Rental pricing is a sum of daily rates
A rental price is not one number from the product page. It depends on how many days, which days, which season, and whether a long rental earns a discount. Written as a formula:
Here is the rental price, is the number of rental days, counts the days from to , is the base daily rate of the variant, is the weekend factor for day (for example on a Saturday or Sunday, otherwise), is the season factor for day (for example in August, otherwise) and is the duration discount as a share, for example when is seven or more and below that. The discount is a percentage taken off the total, see Foundations: percentages.
Where does this live in Solidus? Two facts decide it.
First, a line item gets its price from the variant's price selector, which picks a price for a given set of pricing options. By default those options are only the currency and the country.11 The pricing options for a line item are built by a class method called from_line_item, and both the selector and its options class are configurable.1213
Second, the browser cannot send a price. The default permitted line item attributes are the id, the variant, the quantity and a customer_metadata hash, nothing else.14 And for stores on the Solidus 5.0 defaults, a setting called recalculate_cart_prices is on, which recalculates cart prices from the price selector whenever the order is recalculated.13 A price written onto the line item by hand would be overwritten.
So the rental dates go into the line item's customer_metadata. A custom pricing options class reads them in from_line_item. A custom price selector applies the formula and returns the price. Because the price comes from the selector, every recalculation gives the same answer. One more setting keeps two rentals of the same variant on different dates apart: Solidus lets you add comparison hooks, methods on the order that decide whether a new item is the same as an existing line item.13
Worked example: seven days in August
A customer books the small automatic, 100 miles a day, from Friday 14 August to Friday 21 August 2026. That is days: Friday, Saturday, Sunday, Monday, Tuesday, Wednesday, Thursday. The base rate is . All seven days are in August, so every day. Saturday and Sunday have . These factors are this example's assumptions, chosen to show the arithmetic.
- The weekday rate: .
- The weekend rate: .
- Add the seven days, five weekdays and two weekend days: .
- The duration discount. Seven days earns , so .
The customer pays £340.20. In this example the rates include VAT. For comparison, the three day weekend from Friday to Monday is , with no discount, because three is below seven.
The horizontal axis shows the seven rental days, the vertical axis the rate for each day in pounds, before the 10% discount comes off the total. The takeaway is that the weekend days are where the money is made, and the seven bars added up give the £378.00 that the discount then reduces to £340.20.
A deposit is an authorisation, captured later
Card payments have two steps. Authorising reserves the money on the customer's card. Capturing actually takes it. Solidus is built around this split: its configuration notes that authorising and capturing later handles errors far better, and auto_capture is off by default.13 When an order's payments are processed, each one is either authorised or authorised and captured in one go, depending on the payment method. An authorised payment is captured later, or voided if it is not needed.15 The capture! method takes an amount in pence, so you can capture less than you authorised.16
Stripe works the same way, and a partial capture automatically releases the rest. There is a limit: an authorisation for an online card payment is usually valid for seven days, and a payment not captured in time is cancelled and the money released.17 Holds taken on a card terminal in person are shorter, two days for most card brands.17 For a rental, that window decides when you take the hold: close to collection for short rentals, and Stripe's extended authorisation or a different deposit method for long ones.
The rental price is a normal payment, captured at booking. The deposit is a second payment, on a method with auto capture off.
Worked example: the weekend deposit
A customer rents the small automatic for the weekend, Friday 14 to Monday 17 August. Hannah's security deposit is £250, an assumption for this example.
- At booking, the customer pays the rental price of £176.40. It is captured straight away.
- On Friday morning, the collection email asks the customer to authorise £250 online, on the same card. In Solidus it sits as a pending payment. Nothing has left the customer's account yet. The hold is valid for seven days, and the car is due back in three.
- On Monday the car comes back a quarter of a tank short. Hannah's refuelling charge is £38, again an assumption.
- Her team captures £38 of the £250. In pence, that is
capture!(3800). - Stripe releases the other £212 to the customer: £250 minus £38.
- Had the car come back full and undamaged, the team would void the authorisation and the whole £250 would be released.
The customer only ever pays for what actually happened, and nobody has to process a refund.
A car checkout needs different steps
Out of the box, a Solidus checkout runs: cart, address, delivery, payment if needed, confirm, complete. You change it with an override on the order, using remove_checkout_step and insert_checkout_step with a before or after position.18 A step can also carry a condition, so it only applies to some orders.19
The Solidus guides carry two warnings worth knowing before you remove anything. Remove the address step but keep delivery, and delivery breaks, because it expects an address. And the stock coordinator from earlier runs when the order enters delivery, so if you remove that step, you must call it yourself.188
For Kirkstall Motors, the rental checkout needs one thing a parcel checkout does not: a driving licence check before payment.
Worked example: a licence step for rentals only
The override adds one step, only for orders that contain a rental:
module Kirkstall
module Spree
module Order
module AddLicenceStep
def self.prepended(base)
base.insert_checkout_step :licence, before: :payment, if: ->(order) { order.rental? }
end
::Spree::Order.prepend self
end
end
end
end
The rental? method is Kirkstall's own: true when any line item belongs to the rental taxon. A rental now runs cart, address, delivery, licence, payment, confirm, complete. A used car sale skips the licence step and runs as before. The step needs its own view, a form for the licence details, which the Solidus guide covers next to the code.18
Documents belong on the record they prove
A dealership produces paperwork at every turn: the licence check for a rental, the rental contract, the sales invoice, photos of the car at collection and return. Rails has Active Storage for this. It attaches files to any record with has_one_attached or has_many_attached, and stores them on disk or in a cloud bucket.20
The licence check itself has a public service in the UK. A driver can create a licence check code on GOV.UK and share it with someone else, and the page names a car hire company as the example. The code is valid for 21 days.21
Keep only what you need: the check code, the date and the result. A scan of the licence is personal data you must protect and later delete, the theme of treating privacy as a strength.
Worked example: when to ask for the check code
A customer books a rental on 10 July for collection on 14 August. If the licence step asked for the check code at booking, the code would have expired long before collection: 10 July plus 21 days is 31 July.
So the licence step at checkout only records the licence number and the driver's consent. Then:
- Seven days before collection, on 7 August, an email asks for a check code.
- The customer creates one on GOV.UK and enters it. It is valid until 28 August, well past collection.
- A member of staff checks the record, marks the booking "licence checked" with the date, and the result is stored on the booking.
- At collection, the signed rental contract is generated as a PDF and attached to the booking with
has_one_attached :contract, together with the photos taken at handover.
If a dispute comes up in October, it is all on one record.
The UK rules that shape the build
A few UK rules change how the software must behave. In brief, and not as legal advice:
Cancellation for cars sold online. When a consumer buys goods at a distance, the cancellation period ends 14 days after the day the goods come into their physical possession.22 For a car sold online and delivered, the clock starts when the buyer gets the car, so any deposit and payment logic must allow for a refund in that window.
Rentals with fixed dates. The same regulations exclude vehicle rental services from the cancellation right when the contract provides for a specific date or period.23 A rental for 14 to 17 August is such a contract. Your own cancellation terms still apply, so the shop must say clearly what they are.
Finance. A dealer who introduces customers to a finance provider is credit broking, and needs FCA authorisation. The FCA's page for motor dealers explains which permission applies: a dealer who owns the vehicles it sells and introduces customers to third party lenders can usually apply for limited permission. The FCA also expects your financial promotions to make clear that you are a broker, not a lender.24 A finance calculator on a car page is a promotion, not a design detail.
VAT on used cars. Many used cars are sold under the VAT margin scheme, where VAT is due on the margin, not on the full price. A vehicle bought on an invoice that shows VAT separately is not eligible.25 The margin and the VAT come from two steps:
Here is the gross margin, is the selling price, is the purchase price, and is the VAT fraction that HMRC gives for the 20% standard rate.26 The fraction is , the share of a price that includes 20% VAT, see Foundations: percentages.
Worked example: VAT on the blue Golf
Hannah bought the Golf from a private seller for £14,200, so there was no VAT invoice and the car qualifies. She sells it for £16,495.
- The margin: .
- The VAT: .
She owes £382.50 of VAT on this sale, not the £2,749.17 it would be if VAT were due on the full price. Solidus's default tax calculation works from tax categories, tax rates and rate calculators, and its guide shows two ways to change it: a custom rate calculator, or replacing the tax calculator altogether.27 A margin scheme car needs one of those, fed with the purchase price from the vehicle record, or the VAT has to be worked out outside the order. Decide which before the first car goes live.
Where to start
Each piece is a small extension of the same Solidus core, not a rebuild. A sensible order follows the risk:
- The vehicle data and the one unit stock rule, because everything else reads from them.
- The rental calendar with its exclusion constraint, because a double booking is the most expensive mistake.
- Rental pricing in a price selector, so every recalculation agrees.
- The deposit as an authorisation, with the hold window in mind.
- The checkout steps and the documents.
Email automation and tracking are their own topics: sending order emails safely is in Postmark with Rails and Solidus, and consent aware tracking is in event tracking and consent mode. To see the whole thing running a real business, the auto dealership platform case study shows sales, rentals, the garage, vouchers and marketing working from one admin.
Footnotes
-
Solidus, Solidus README, GitHub, 2026, https://github.com/solidusio/solidus
-
Solidus, core/app/models/spree/product.rb, GitHub, 2026, https://github.com/solidusio/solidus/blob/main/core/app/models/spree/product.rb
-
Solidus, core/app/models/spree/stock_item.rb, GitHub, 2026, https://github.com/solidusio/solidus/blob/main/core/app/models/spree/stock_item.rb
-
Solidus, core/app/models/spree/variant.rb, GitHub, 2026, https://github.com/solidusio/solidus/blob/main/core/app/models/spree/variant.rb
-
Solidus, core/app/models/spree/option_type.rb, GitHub, 2026, https://github.com/solidusio/solidus/blob/main/core/app/models/spree/option_type.rb
-
Solidus, Customizing the core, Solidus Guides, 2026, https://guides.solidus.io/customization/customizing-the-core/
-
Solidus, core/app/models/spree/stock_location.rb, GitHub, 2026, https://github.com/solidusio/solidus/blob/main/core/app/models/spree/stock_location.rb
-
Solidus, Stock management, Solidus Guides, 2026, https://guides.solidus.io/advanced-solidus/stock-and-fulfillment/
-
The PostgreSQL Global Development Group, Range Types (section 8.17), PostgreSQL Documentation, 2026, https://www.postgresql.org/docs/current/rangetypes.html
-
Ruby on Rails, Active Record and PostgreSQL, Rails Guides, 2026, https://guides.rubyonrails.org/active_record_postgresql.html
-
Solidus, core/app/models/spree/variant/price_selector.rb, GitHub, 2026, https://github.com/solidusio/solidus/blob/main/core/app/models/spree/variant/price_selector.rb
-
Solidus, core/app/models/spree/variant/pricing_options.rb, GitHub, 2026, https://github.com/solidusio/solidus/blob/main/core/app/models/spree/variant/pricing_options.rb
-
Solidus, core/lib/spree/app_configuration.rb, GitHub, 2026, https://github.com/solidusio/solidus/blob/main/core/lib/spree/app_configuration.rb
-
Solidus, core/lib/spree/permitted_attributes.rb, GitHub, 2026, https://github.com/solidusio/solidus/blob/main/core/lib/spree/permitted_attributes.rb
-
Solidus, Payments and refunds, Solidus Guides, 2026, https://guides.solidus.io/advanced-solidus/payments-and-refunds/
-
Solidus, core/app/models/spree/payment/processing.rb, GitHub, 2026, https://github.com/solidusio/solidus/blob/main/core/app/models/spree/payment/processing.rb
-
Stripe, Place a hold on a payment method, Stripe Documentation, 2026, https://docs.stripe.com/payments/place-a-hold-on-a-payment-method
-
Solidus, Redefining checkout steps, Solidus Guides, 2026, https://guides.solidus.io/cookbook/redefining-checkout-steps/
-
Solidus, core/app/models/spree/core/state_machines/order/class_methods.rb, GitHub, 2026, https://github.com/solidusio/solidus/blob/main/core/app/models/spree/core/state_machines/order/class_methods.rb
-
Ruby on Rails, Active Storage Overview, Rails Guides, 2026, https://guides.rubyonrails.org/active_storage_overview.html
-
Driver and Vehicle Licensing Agency, View or share your driving licence information, GOV.UK, 2026, https://www.gov.uk/view-driving-licence
-
UK Government, The Consumer Contracts (Information, Cancellation and Additional Charges) Regulations 2013, regulation 30, legislation.gov.uk, 2013, https://www.legislation.gov.uk/uksi/2013/3134/regulation/30
-
UK Government, The Consumer Contracts (Information, Cancellation and Additional Charges) Regulations 2013, regulation 28(1)(h), legislation.gov.uk, 2013, https://www.legislation.gov.uk/uksi/2013/3134/regulation/28
-
Financial Conduct Authority, Motor dealers, FCA, 2026, https://www.fca.org.uk/firms/authorisation/consumer-credit-brokers/motor-dealers
-
HM Revenue and Customs, Buying and selling second hand motor vehicles using the VAT margin scheme, GOV.UK, 2026, https://www.gov.uk/guidance/buying-and-selling-second-hand-motor-vehicles-using-the-vat-margin-scheme
-
HM Revenue and Customs, How to work out the margin and VAT due using VAT margin schemes, GOV.UK, 2026, https://www.gov.uk/guidance/how-to-work-out-the-margin-and-vat-due-using-vat-margin-schemes
-
Solidus, Taxation, Solidus Guides, 2026, https://guides.solidus.io/advanced-solidus/tax-calculation/