Skip to content

Latest commit

 

History

History
305 lines (212 loc) · 18.4 KB

File metadata and controls

305 lines (212 loc) · 18.4 KB

hotel-rates

Set every one of your Hotel Mate rate plans from one base price per room, instead of pricing each channel by hand.

Point it at the direct price you charge for a room in a season. It works out what the IBE (your own booking engine), OTA and TA (travel agent) rate plans have to be as flat percentage markups on that FIT price, converts and rounds a resident (local-currency) rate if the room sells one, and writes all of it to Hotel Mate over its own REST API — then asks Hotel Mate's channel manager to distribute it to the OTAs.

npm run login                      # log in once, in a real browser
npm run rate-plans                 # list every rate plan and its id, for config
npm run plan -- --to 2027-06       # see what it would write
npm run push -- --to 2027-06       # write it
npm run verify -- --to 2027-06     # read the rates back and diff them against the plan

Why direction matters

Your direct price (FIT) is the master. IBE, OTA and TA are flat percentage markups on top of it, not chosen independently:

IBE price  =  FIT price × ibeMultiplier
OTA price  =  FIT price × otaMultiplier
TA price   =  FIT price × taMultiplier

Change the FIT price, re-run, and every other channel follows.

As of 2026-09-10 this replaced an earlier model that solved the OTA price from a worst-case guest discount stack (loyalty, mobile, ...), so that a fully-discounted OTA guest still paid more than booking direct. That guarantee doesn't exist under flat markups — nothing here models what a guest actually pays after their own OTA-side discounts. The one thing still checked, once at config load, is that otaMultiplier and taMultiplier each exceed ibeMultiplier, so the listed OTA/TA price is never at or below the direct one.


Install

Requires Node 20+.

git clone https://github.com/acekavi/booking-extranet-bot.git
cd booking-extranet-bot
npm install
npx playwright install chromium   # needed once, only for the login step

Configure

cp config/hotelmate.example.json config/hotelmate.json

Your real config is gitignored — see Keeping your data out of git.

{
  "hotelId": 1,
  "pricing": {
    "model": "flat-markup-on-fit",
    "ibeMultiplier": 1.05,                  // IBE = FIT × this
    "otaMultiplier": 1.3,                   // OTA = FIT × this; must exceed ibeMultiplier
    "taMultiplier": 1.15                    // TA  = FIT × this; must exceed ibeMultiplier
  },
  "seasons": {
    "months": { "Peak": [12, 1, 2, 3], "Mid": [4, 11], "Off": [5, 6, 7, 8, 9, 10] },
    "multipliers": { "Peak": 1.8, "Mid": 1.4, "Off": 1 }
  },
  "resident": {
    "currency": "LKR",
    "roundDownStep": 1000,                  // round resident prices DOWN to this step; 0 disables
    "fxUrl": "https://open.er-api.com/v6/latest/USD",
    "fxMaxAgeHours": 48
  },
  "minStay": 0,
  "rooms": {
    "Example Room": {
      "hotelRoomTypeId": 100,
      "capacity": 2,
      "baseOffSeasonPrice": 100,            // the FIT price for the base season; everything else derives from this
      "plans": { "FIT": 1001, "IBE": 1002, "OTA": 1003, "TA": 1005 },
      "residentPlanId": 1004                // optional: the local-currency IBE plan for residents
    }
  }
}

otaMultiplier and taMultiplier are the ones to get right: each must be greater than ibeMultiplier, or npm run rate-plans/plan/push/verify refuse to load the config at all — that's the only guarantee left that a direct booking lists cheaper than an OTA or travel-agent one.

Every month must belong to exactly one season; the loader rejects gaps and overlaps rather than picking for you. Two rooms are not allowed to share a rate plan id — one would silently overwrite the other's price.

hotelRoomTypeId and the rate plan ids per room (FIT, IBE, OTA, the optional TA, and the optional residentPlanId) come from npm run rate-plans, below — this tool does not create rate plans, only prices ones you already have. TA is optional: skip it for a room that doesn't sell a travel-agent rate.

Find your rate plan ids

npm run rate-plans
14 rate plan(s):

  1001    FIT   Deluxe Double            RO   USD
  1002    IBE   Deluxe Double            RO   USD
  1003    OTA   Deluxe Double            RO   USD
  1004    IBE   Deluxe Double            RO   LKR
! 1077    FIT   Deluxe Double            BB   USD

! 1 plan(s) are NOT managed by this tool. Rate plans cannot be
  deleted in Hotel Mate, so these keep whatever price they already have — and a
  stale one can undercut everything computed here. Check them by hand.

Rate plans marked ! are not addressed by any room in your config. Hotel Mate has no way to delete a rate plan, so a plan left over from an earlier setup — a discontinued breakfast rate, a test plan — keeps whatever price it last had, and can undercut everything this tool computes without you ever setting it. Check these by hand; this list is the only defence.

Breakfast (BB) rate plans

BB (bed & breakfast) rate plans are priced by hand directly in Hotel Mate, not by this tool — the markup Hotel Mate charges for breakfast doesn't follow the same flat-percentage-on-FIT model the RO (room only) plans do (see below). Each room's confirmed BB plan ids are recorded in config/hotelmate.json under breakfastPlans, but only so rate-plans/plan stop flagging them as unmanaged — nothing in this tool ever computes or writes a price to one.

Verified against live rates on 2026-09-11 (don't assume these still hold — Hotel Mate's own breakfast pricing can change independently of this tool and nothing re-checks it):

  • FIT: a flat $9.14 per adult, added to the FIT room-only price, the same across every room regardless of its base price. That number lines up almost exactly with a round 3,000 LKR per person at the exchange rate on hand at the time (3000 ÷ 328.26 ≈ 9.14) — the breakfast surcharge looks like it was set in LKR, not USD.
  • IBE: exactly FIT_BB × ibeMultiplier — i.e. the FIT price with breakfast already added is what gets the normal channel markup, not a separately-computed IBE breakfast charge.
  • IBE resident (LKR): a flat 2,500 LKR per adult, added to the resident room-only price — consistent across every room, but not derived from the USD figures above by any exchange rate this tool knows about.
  • OTA: does not follow a consistent per-person or multiplier rule — three different rooms with the same 2-person capacity show three different breakfast uplifts (10.42–11.43 per person). This looks like it predates the FIT/IBE pattern above and was set by hand per room. Confirmed correct by the property owner regardless (2026-09-11), but if you're ever asked to explain why an OTA BB price is what it is, the honest answer is "no known formula, verify by hand" rather than inventing one.

If Hotel Mate's own breakfast pricing changes, re-verify with readRates against the ids in breakfastPlans (the same function verify uses) rather than assuming the numbers above still hold.

Log in — once

npm run login

A browser opens. Log in yourself, including 2FA. When you can see the dashboard, press Enter, and the bearer token is read out of the page and saved to .auth/hotelmate.json (gitignored). Every later command reuses it, and refreshes it automatically for as long as the refresh token stays valid.

It deletes the hardest part of the problem. The login page is the most defended and most frequently redesigned surface on any such app — new device checks, changed 2FA prompts, occasional CAPTCHAs. Automating it means writing the most fragile code in the project and then repairing it forever. Handing that one step to a human removes the entire category.

It is what makes this repo safe to publish. There is no password handling, no TOTP implementation and no credential storage anywhere in the source — so there is no credential bug to have, and nothing sensitive to leak if you fork this or make your copy public.

The cost is that the saved session expires eventually, and you re-run npm run login when it does — readTokens tells you plainly when there is nothing saved to read.

This replaced an earlier implementation (still in packages/booking-extranet/) that drove the Booking.com extranet's own login form. That code was the single most breakage-prone part of the project. Hotel Mate exposes a documented REST API, so everything past login now talks to that API directly instead.

Plan, then push

npm run plan -- --to 2027-06
2026-09 .. 2027-06 (10 month(s)), from 2026-09-09
1 room(s), 3 range(s) to write (+1 resident).
USD to LKR at 305.4 (fetched 2026-09-09T08:12:00.000Z).
  Deluxe Double            FIT       2026-09-09 → 2027-06-30   100.00 USD
  Deluxe Double            IBE       2026-09-09 → 2027-06-30   103.09 USD
  Deluxe Double            RESIDENT  2026-09-09 → 2027-06-30   31000.00 LKR
  Deluxe Double            OTA       2026-09-09 → 2027-06-30   150.72 USD

This was a plan. Nothing was written. Run `npm run push` with the same flags to apply.

Nothing is written. When it looks right:

npm run push -- --to 2027-06
Flag
--from YYYY-MM first month to price (default: this month)
--to YYYY-MM last month to price (required)
--start-date DATE clamp the first month (default: today)
--resume FILE skip writes already recorded in a previous journal

push refuses to write anything if Hotel Mate's own dynamic pricing is turned on for the property (it would move every rate again right after this tool sets it), if the exchange rate could not be fetched and no usable cached one exists, or if any OTA price it is about to write would let a guest pay at or below the matching direct price — the one invariant this whole tool exists to guarantee.

Resident rates

A room with residentPlanId set gets one extra write alongside each IBE range: the IBE amount converted to the resident currency at the current exchange rate and rounded down to roundDownStep, written to that room's resident rate plan. plan and push both show it; verify checks it back the same as every other rate. Resident rate plans are the property's own local listing, not something sold through an OTA, so they are priced but never pushed to the channel manager.

Verifying and resuming

npm run verify -- --to 2027-06

reads every rate this run would have written straight back from Hotel Mate and diffs it against the plan, date by date, and exits non-zero if anything differs or was never set.

Every write is appended to output/journal-<timestamp>.csv as it happens, not at the end. So if a push dies partway through, you still have a record of what landed:

npm run push -- --to 2027-06 --resume output/journal-1234567890.csv

It skips what is already recorded and carries on.


How it avoids looking like a problem

Short version: it does not try to hide. It logs in as you, once, in a real browser, and everything after that talks to Hotel Mate's own documented REST API and does the same edits you would do by hand through its panel — slower, and fewer of them. The goal is to not be a nuisance to the platform, not to be undetectable by it.

One request per date range, not one per night. Rates are collapsed into ranges (a whole season at one price is a single call) rather than written date by date.

Randomised pauses between writes. 400–1000 ms after each range, so a run reads as one person working through their pricing, not a script hammering an endpoint.

Nothing is fired at a fixed interval and nothing retries blindly. A failed write fails the run loudly rather than looping.

plan touches nothing. The default posture is to compute and print. Only push writes, and it prints the same plan first.

You log in; it doesn't. There is no automated login, no stored password, no TOTP handling and no CAPTCHA solving. The tool inherits a bearer token you obtained by hand and refreshes it until it expires — see Log in — once.

The thing that actually matters

Volume and rhythm, not fingerprint. A few dozen writes once a month, in the daytime, from a property manager's own session, looks like a property manager.

  • Run it when you would naturally be working. Not at 4am, not every hour.
  • Push when prices change, not on a schedule. This is a tool for a pricing decision, not a heartbeat.
  • Use plan first, every time. It costs nothing and catches the mistake that would otherwise become dozens of wrong writes.
  • If a run is interrupted, resume it — don't rerun the whole range. --resume skips what already landed.

And check your agreement

Your contract with Hotel Mate governs what you may automate against your own property, and it is the authority here, not this README. Read it. If it prohibits automated access, this tool is not a loophole.


Keeping your data out of git

This repo is public and contains no property's data. That is enforced, not promised:

  • config/*.json is gitignored; only config/*.example.json is committed
  • .env, .auth/ (including the saved token and the cached exchange rate) and output/ are gitignored
  • packages/domain/ — most of the pricing logic — is pure arithmetic that imports no I/O at all, so it cannot hold a credential, a hotel id, or a rate plan id
  • test fixtures use round, obviously-fake numbers

and a check you can run, which scans the files git actually tracks for connection strings, private keys, API tokens, email addresses and bare 8+ digit runs that look like ids:

npm run check-clean

It exits non-zero on a finding, so it works as a pre-commit hook:

echo 'npm run check-clean' > .git/hooks/pre-commit && chmod +x .git/hooks/pre-commit

A warning worth taking seriously. Your rates, occupancy and margins are commercially sensitive even though they are not secrets. Prices are visible on the OTA anyway, but your commission rate, your direct margin and your booking volumes are not — and together they are a competitor's map of your business. Keep your config/ private, and think twice before committing analytics or revenue notes to any repo you might later make public.

What this tool does not do

  • It will not catch an extra rate plan that undercuts your direct price. A plan push doesn't manage — a weekly rate, a last-minute deal, or any plan left over from before the 2026-09-10 pricing-model change — keeps whatever price it already has, and nothing checks it against the current FIT price. npm run rate-plans is what surfaces unmanaged plans (marked !) so you can check them by hand; there is no automated check for this any more, unlike under the earlier discount-stack model.
  • It does not create or edit rate plans, only prices ones you already have and have listed in config/hotelmate.json.
  • It does not manage availability or read reservations. It prices rate plans; inventory is Hotel Mate's own concern.

Booking.com verification commands

packages/booking-extranet is the earlier tool this project started as — Hotel Mate is now the only writer. What remains is read-only, kept around so you can sanity-check what Hotel Mate's channel manager actually pushed to Booking.com:

npm run extranet:plan                 # print the Booking.com-derived prices
npm run extranet:recon                # browse the Booking.com extranet interactively
npm run extranet:login                # log in once, for the two commands above
npm run extranet:browser              # start a browser recon can attach to across runs
npm run extranet:browser:status       # is one running, and where
npm run extranet:browser:stop         # shut it down

extranet:plan shows the OTA prices this package's own pricing model derives from config/pricing.json, so you can compare them against what Hotel Mate actually wrote. extranet:recon opens a REPL against the live extranet page for looking things up by hand; it is read-only by default (pass --unsafe to allow writes). Neither command writes anything to Booking.com.

HotelMate dashboard recon

Same tool, a second instance, pointed at Hotel Mate's own dashboard instead of Booking.com's extranet. Use it to look at a page, or capture the exact request an unfamiliar dashboard action sends — this project's own API modules (writeRateRange in particular) exist because a schema-shaped guess at a write payload returned HTTP 500; the real shape had to be read off the wire from what the dashboard itself sends. This is how:

npm run hotelmate:recon-login                # log in once, for the two commands below
npm run hotelmate:recon                      # browse the live dashboard interactively
npm run hotelmate:browser                    # start a browser recon can attach to across runs
npm run hotelmate:browser:status             # is one running, and where
npm run hotelmate:browser:stop               # shut it down

It's a separate login from npm run login above — that one throws the browser away and keeps only the bearer token for direct API calls; this one keeps the whole dashboard session, because recon needs to load pages this project has no typed API call for yet. It runs its own browser profile and CDP port (9223, vs. the Booking.com recon's 9222), so both can run side by side.

recon is read-only by default, same guarantee as extranet:reconclick() refuses anything whose label looks like it commits a change (Save, Delete, Take Payment, Post Charge, ...) unless you call arm() or start it with --unsafe. That guard is a seatbelt, not a cage: it cannot see a commit button it doesn't recognize, and rawPage/page.evaluate() bypass it entirely. This is a live property's real reservations and real money — treat an armed session accordingly.

Development

npm test           # unit tests
npm run build      # typecheck
npm run check-clean
packages/
  domain/            pure logic — no network, no filesystem, no browser
  sources/           where the exchange rate comes from
  hotelmate/          the Hotel Mate CLI: auth, HTTP client, rate-plan reads and writes, config, journal
  booking-extranet/   the earlier Booking.com extranet tool this project started as

The rule that keeps this honest: packages/domain/ imports nothing with I/O. Anything property-specific enters through packages/hotelmate/src/config.ts, so a review only has to look in one place per package.

License

MIT — see LICENSE.