# Pricing & The Estimator

## Pricing & The Estimator

**Files:** `src/content/pricing/english/-index.md`, `src/content/pricing/french/-index.md`, `src/config/config.toml` (`[settings.estimator]`) · **Skill level:** Beginner

<div class="border border-dark">
  ![Homio estimator on the pricing page](/images/homio/pricing.jpg)
</div>

Every price in Homio comes from **one file per language**: `src/content/pricing/english/-index.md` (and the French copy). The estimator, the plans, the add-ons, the trade rate sheet, the booking form total, the "from" prices on service and area pages, and the prices in structured data all read it. Change a rate there and every page that shows it updates on the next build. You never type the same price twice.

<Aside type="caution" title="Every number is a sample">
  The rates, discounts, minimums, and add-on prices that ship with Homio are
  examples. Each one is marked `# sample` in the file. Replace all of them with
  your own prices before you launch.
</Aside>

### What Is In The Pricing File
The top of the file is the pricing page (title, page header, jump links). Under the line `PRICE DATA` come four blocks:

| Block         | What it holds                                                                                  |
| ------------- | ---------------------------------------------------------------------------------------------- |
| `rounding`    | The step that discounted prices round to (`5` rounds to the nearest 5)                         |
| `frequencies` | How often a visit repeats (weekly, every 2 weeks, monthly, one-time) and the discount for each |
| `rateCards`   | One card per bookable service: how it is priced, its limits, and its minimum                   |
| `addOns`      | Extras with a fixed price, such as "Inside the oven"                                           |

The page sections (`plansSection`, `estimateSection`, `addOnsSection`, `ratesSection`, `includedSection`, `questionsSection`, `ctaSection`) come after the data. They set titles and layouts only. Their prices are computed from the data above.

### Frequencies And Discounts
```yaml
rounding: 5 # Discounted service prices round to the nearest 5. Use 1 to keep exact amounts

frequencies: # Discount applies to the service line only, never to add-ons
  - id: "weekly" # used in /book/?frequency=
    label: "Weekly"
    discount: 20 # percent, sample
  - id: "biweekly"
    label: "Every 2 weeks"
    discount: 15 # sample
    default: true # preselected everywhere
  - id: "monthly"
    label: "Monthly"
    discount: 10 # sample
  - id: "once" # keep the id `once` for the one-visit option
    label: "One-time"
    discount: 0
```

- `discount` is a percent from `0` to `100`.
- `default: true` marks the frequency that is selected first. Put it on one frequency only.
- Keep the id `once` for the one-visit option. The ticket then says "one visit" instead of "per visit".
- Change the `label` freely. Keep the `id` values: they appear in booking links (`/book/?frequency=biweekly`).

### Rate Cards
Each rate card is one service you can price online. Its `id` must match the file name of the service page in `src/content/services/english/` (`standard-cleaning` for `standard-cleaning.md`). That link gives the service page its "from" price and its estimator. A service without a rate card shows "Price on request".

A card uses one of three pricing modes:

| `mode`   | The visitor sets                             | Price of the service line                                        |
| -------- | -------------------------------------------- | ---------------------------------------------------------------- |
| `rooms`  | Bedrooms and bathrooms                       | `base` + bedrooms × `perBedroom` + bathrooms × `perBathroom`     |
| `hourly` | Hours                                        | `callOutFee` + the larger of (hours, `hours.min`) × `hourlyRate` |
| `units`  | A count: panes, rooms, items, sq ft, storeys | `base` + units × `perUnit`                                       |

A rooms card, from the shipped file:

```yaml
rateCards:
  - id: "standard-cleaning"
    label: "Standard clean"
    trade: "cleaning" # trade id from src/content/services/{lang}/-index.md `trades:`
    mode: "rooms" # rooms | hourly | units
    base: 70 # sample
    perBedroom: 20 # sample
    perBathroom: 25 # sample
    minimum: 110 # sample; smallest total for one visit
    recurring: true # frequency discounts apply
    frequencies: [] # ids allowed for this card; empty = all
    bedrooms: { min: 0, max: 6, default: 2 } # 0 reads "Studio"
    bathrooms: { min: 1, max: 5, default: 1 }
    duration: { base: 1, perBedroom: 0.5, perBathroom: 0.5 } # hours, optional
    note: "Supplies and equipment included."
    largerNote: "More than 6 bedrooms? Call {phone} for a walkthrough price."
```

An hourly card and a units card:

```yaml
- id: "handyman"
  label: "Handyman"
  trade: "repairs"
  mode: "hourly"
  hourlyRate: 75 # sample
  callOutFee: 0 # sample; added once per visit
  hours: { min: 2, max: 8, step: 0.5, default: 2 }
  recurring: false
  note: "Parts are billed at cost with receipts."
- id: "window-cleaning"
  label: "Windows"
  trade: "windows"
  mode: "units"
  unit: { one: "pane", other: "panes" } # plural forms for this language
  base: 40 # sample
  perUnit: 6 # sample
  minimum: 90 # sample
  units: { min: 5, max: 80, step: 1, default: 20 }
  recurring: true
  frequencies: ["monthly", "once"]
```

| Key                                       | What it does                                                                                                                  |
| ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `id`                                      | The service id. Same as the service file name, and the value in `/book/?service=`                                             |
| `label`                                   | The service name on tickets, chips, and the booking form                                                                      |
| `trade`                                   | The trade it belongs to (`cleaning`, `repairs`, `windows`, `carpets`, `outdoor`). Groups the trade rate sheet and the add-ons |
| `mode`                                    | `rooms`, `hourly`, or `units` (see above)                                                                                     |
| `base`, `perBedroom`, `perBathroom`       | Prices for `rooms` cards. `base` also works on `units` cards                                                                  |
| `hourlyRate`, `callOutFee`                | Prices for `hourly` cards. `callOutFee` is added once per visit                                                               |
| `perUnit`, `unit`                         | Price per unit and the unit name in singular (`one`) and plural (`other`)                                                     |
| `bedrooms`, `bathrooms`, `hours`, `units` | The limits of each number field: `min`, `max`, `step` (optional), and the starting value `default`                            |
| `minimum`                                 | The smallest total for one visit, add-ons included                                                                            |
| `recurring`                               | `true` lets frequency discounts apply. `false` (or left out) prices a one-off service with no frequency choice                |
| `frequencies`                             | The frequency ids this card offers. Empty means all of them                                                                   |
| `duration`                                | Optional time on site: `base`, plus `perBedroom` and `perBathroom` (rooms) or `perUnit` (units). Shown as "About 2.5 h"       |
| `note`                                    | A short line on the ticket, such as "Supplies and equipment included."                                                        |
| `largerNote`                              | Shown when the visitor reaches the largest size. `{phone}` becomes your business phone                                        |

When a card leaves out a limit, the theme uses these fallbacks: bedrooms 0 to 6 (start 2), bathrooms 1 to 5 (start 1), hours 1 to 8 in steps of 0.5 (start 2), units 1 to 100 (start 1).

### Add-ons
```yaml
addOns:
  - {
      id: "inside-oven",
      label: "Inside the oven",
      description: "Racks, glass, and walls degreased.",
      price: 35,
      services: ["standard-cleaning", "deep-cleaning"],
      task: "inside-oven",
    } # sample
  - {
      id: "sofa",
      label: "Sofa, 3 seats",
      description: "Fabric only, dry in about 6 hours.",
      price: 95,
      services: ["carpet-cleaning"],
    } # sample
```

| Key           | What it does                                                                                                                                                                           |
| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `price`       | A flat price. Frequency discounts never apply to add-ons                                                                                                                               |
| `services`    | The rate-card ids it can be added to. Empty means every service                                                                                                                        |
| `task`        | Optional task id from the "what's included" list. The checklist then shows the add-on price on that task (see [Services & What's Included](/homio/customization/services-included/)) |
| `description` | Optional short detail, shown in the add-ons section                                                                                                                                    |

### How The Estimate Is Calculated
The formula is in `src/lib/pricing.ts`. The estimator, the booking summary, the plans, and the "from" prices all call it, so two pages never show different prices for the same job.

<Steps>

1. **Service line.** Work out the line for the card's mode (the table under Rate Cards). A number outside the card's limits is moved to the nearest allowed value. The result is rounded to a whole amount.
2. **Frequency discount.** If the card is `recurring` and the visitor picked a frequency with a discount, take the percent off the service line, then round to the nearest `rounding` (5 by default). The difference shows as its own discount line.
3. **Add-ons.** Add the price of each selected add-on. Add-ons are never discounted.
4. **Minimum.** If the total so far is below the card's `minimum`, the total is raised to the minimum and the ticket shows a "Minimum" line with the difference.
5. **Time on site.** If the card has a `duration`, it is worked out the same way and rounded to the nearest half hour. Hourly cards show the hours chosen.

</Steps>

Worked examples with the shipped sample rates:

- **Standard clean, 2 bedrooms, 1 bathroom, every 2 weeks:** 70 + 2 × 20 + 1 × 25 = **$135**. 15% off is 114.75, rounded to the nearest 5 = **$115**. The ticket shows a "−$20" discount line and "About 2.5 h".
- **Same visit with "Inside the oven":** $115 + $35 = **$150**.
- **Studio (0 bedrooms), 1 bathroom, weekly:** 70 + 25 = $95, 20% off = $75. That is below the $110 minimum, so the total is **$110** with a "+$35" minimum line.
- **Handyman, 2 hours:** 0 + 2 × 75 = **$150**.
- **Windows, 20 panes, monthly:** 40 + 20 × 6 = $160, 10% off = 144, rounded = **$145**.

**"From" prices** (services list, booking tiles, area pages, structured data) use the smallest allowed size, one visit, and no add-ons, then apply the minimum. The Standard clean is 70 + 0 × 20 + 1 × 25 = $95, raised to its $110 minimum, so it shows "from $110". Hourly cards show the hourly rate ("$75 / h").

Check any change by hand with these steps, then open `/pricing/` and compare the ticket.

### Where Prices Appear
| Place                                 | What it shows                                                                                                                    | Where you set it                                                                       |
| ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
| `/pricing/`                           | Plans, the estimator with its price breakdown, add-ons, the trade rate sheet, the cleaning comparison, and the payment questions | The page blocks in the pricing file                                                    |
| Cleaning service pages                | The estimator in the hero, locked to that service                                                                                | `options.layout: "cleaning"` in the service file                                       |
| Service pages on the `default` layout | An estimate section locked to that service, with the breakdown                                                                   | `estimateSection` in the `detail:` block of `src/content/services/english/-index.md`   |
| `/services/`                          | "from $110 / visit" on each row, and the lowest price in the page header                                                         | Computed from the rate cards                                                           |
| `/book/`                              | The "from" price on each service tile and the live total                                                                         | Computed from the rate cards                                                           |
| `/home-cleaning/`                     | The recurring-discount plans (`savings` layout) and the first booking step                                                       | `plansSection` and `bookingSection` in `src/content/homepage/english/home-cleaning.md` |
| Area pages                            | "Standard clean from $110" on the area ticket                                                                                    | The first rate card the area offers                                                    |

The page sections in the pricing file:

```yaml
estimateSection: # defaults: estimate-section.md
  title: "Price your home"
  services: [
      "standard-cleaning",
      "deep-cleaning",
      "move-out-cleaning",
      "office-cleaning",
    ] # rate-card ids in the picker
  service: "standard-cleaning" # preselected
  ledger: true # price breakdown under the intro (collapsed under the ticket on phones)

ratesSection: # a second plans section, rendered with the `hourly` layout (trade rate sheet)
  title: "Repairs, windows, carpets, and outdoor"
  note: "All rates include travel inside our areas."
  trades: [] # empty = every trade with an hourly or per-unit rate card
  options:
    layout: "hourly" # default | hourly | table | savings
```

- `estimateSection.services` lists the services in the estimator's picker, in order. Empty offers all of them. Up to 5 show as chips, 6 or more as a list.
- `ledger: true` shows the "How this price is built" breakdown next to the estimator.
- The defaults of every section are in `src/content/sections/english/` (`estimate-section.md`, `plans-section.md`, `add-ons-section.md`).

### Plans
A plan is a rate card at a reference home size. You never type a plan price:

```yaml
plansSection:
  description: "Prices for a 2-bed, 1-bath home."
  showDiscounts: true # appends the recurring discounts from `frequencies` to the description
  plans:
    - name: "Standard clean"
      rateCard: "standard-cleaning"
      home: { bedrooms: 2, bathrooms: 1 }
      featured: true
      badge: "Booked most" # sample
      features: ["Every room on the standard checklist", "Same crew each visit"]
      button: {
          enable: true,
          label: "Book Standard",
          url: "/book/",
          variant: "fill",
        } # the selection is added as a query string
```

- `rateCard` and `home` set the price. A size outside the card's limits is moved inside them, and the build prints a `[plans]` warning.
- `customPrice: "Quote"` shows text instead of a price.
- The plan button opens the booking form with the plan's service and size already chosen.
- Layouts: `default` (plan cards), `hourly` (trade rate sheet), `table`, `savings` (the recurring discounts as tiles). See [Section Layouts](/homio/customization/section-layouts/).

### Currency And Number Formats
The currency is set once, for every language, in `src/config/config.toml`:

```toml
[settings.estimator]
  enable          = true
  currency        = "USD" # ISO 4217 code, one currency for every language
  currencyDisplay = "symbol" # symbol ($ / $US) | narrowSymbol ($ in every language) | code (USD)
  bookUrl         = "/book/" # "Book this visit" and the no-JS form go here, with the selection in the query string
  ticketPrefix    = "EST" # Mono code on the ticket, e.g. EST-4K7Q2
  animateTotal    = true # Count between totals; always off with reduced motion
```

Each language formats the same amount its own way:

| `currency` / `currencyDisplay` | English   | French    |
| ------------------------------ | --------- | --------- |
| `USD` / `symbol`               | $1,240    | 1 240 $US |
| `USD` / `narrowSymbol`         | $1,240    | 1 240 $   |
| `EUR` / `symbol`               | €1,240    | 1 240 €   |
| `GBP` / `symbol`               | £1,240    | 1 240 £GB |
| `USD` / `code`                 | USD 1,240 | 1 240 USD |

Whole amounts show without decimals; other amounts (such as `perUnit: 0.09`) show two. Write the numbers in the pricing file as plain numbers (`75`, `0.09`), never with a currency sign.

`enable = false` hides the estimator everywhere. `animateTotal = false` stops the total from counting up between values.

### Keep English And French Identical
Each language has its own pricing file, so the labels can be translated. The **numbers must match**. The build compares every number in the French file with the English one and prints a warning that names each difference:

```text
[pricing] Numbers in src/content/pricing/french/-index.md differ from src/content/pricing/english/-index.md: rateCards.handyman.hourlyRate
```

The key reads as `block.id.field`. Fix the French file until the warning is gone. Translate `label`, `note`, `largerNote`, `description`, and `unit`; keep every `id` the same in both files.

The pricing collection has a schema, so `npm run astro-check` reports a missing `mode` or a price written as text. If the file has an error, the build stops with a schema error that names the file and the field.

### Prices Typed As Text
A few places show a price as written, not computed. They do not update when you change a rate, so check them after a price change:

- The step previews in `src/content/sections/english/steps-section.md`, `src/content/pages/english/how-it-works.md`, and the `repair` template in `src/content/services/english/-index.md` (for example `value: "$115"`).
- Fees in questions and policies: `questionsSection` in the pricing file (the late-cancellation fee), `src/content/pages/english/cancellation-policy.md`, `src/content/faq/english/-index.md`.
- Project prices (`price.amount` in `src/content/projects/`), gift card amounts (`src/content/pages/english/gift-cards.md`), and the insurance value in `src/content/sections/english/trust-strip.md`.

Search your content for the currency sign to find the rest.

The estimate ticket itself (the card with the mono code and the total) is documented in [The Job Ticket](/homio/customization/job-ticket/).

### Do It With AI
```text
Set my prices in src/content/pricing/english/-index.md. Currency: [ISO CODE] in
[settings.estimator] in src/config/config.toml. Standard clean: base [X], per bedroom [X], per
bathroom [X], minimum [X]. Deep clean: [...]. Handyman: [X] per hour, [X] hours minimum.
Discounts: weekly [X]%, every 2 weeks [X]%, monthly [X]%. Add-ons: [NAME: PRICE; ...]. Keep every
id and key, remove the `# sample` comment from each value you replace, and copy the same numbers to
src/content/pricing/french/-index.md. Then work out a 2-bed, 1-bath visit every 2 weeks with the
formula in src/lib/pricing.ts and show me the total.
```

```text
Search my src/content folder (English and French) for prices typed as text, such as "$115" in
step previews, fees in questions and policies, and project prices. List each one with its file,
and update them to match my pricing file.
```