A five-rental-type booking system living on one shared inventory
From "how many boats do we have free today?" to a signed contract, invoice, and payment — without a single line of Excel. One custom WordPress plugin manages canoeing, the sauna and hot tub, overnight stays, SUP rentals, and packages, all from the same inventory table.
Designed, built, and maintained by Westtro.
The logo and visual identitywere also designed by Westtro
A family business on the banks of the Venta
Upes Viesi is Sanita and Arts Veinbergs's family tourism farmstead in Nīgrande parish, Saldus Municipality — on the Venta, Latvia's longest river, which runs entirely within the country's borders. The core activity is canoeing — guided and self-service canoe/kayak routes — complemented by a mobile sauna and hot tub with delivery, overnight stays in tent sites and apartments, hourly rentals of SUP boards and clear-bottom boats, and packages that bundle several services into one price. The service was built with support from an EU rural development project.
The length of the Venta — Latvia's longest river runs entirely within its borders.
of it navigable by boat within Latvia.
family runs everything — the system is built for their use, not an IT department's.
One inventory, five rental types, zero off-the-shelf plugins
Standard WordPress booking plugins assume one thing gets rented in one category. Upes Viesi simultaneously rents boats by the day, the sauna/hot tub with a delivery and return time, tent sites by the night, SUP boards in half-hour blocks, and packages that combine all of the above — from the same inventory, in real time, with no double-booking risk.
On top of that come Latvia-specific requirements no generic plugin handles out of the box: VAT-exemption wording on the invoice for a self-employed service provider, GDPR data-retention and anonymization deadlines, a legally binding rental agreement with a variable list of safety clauses, and three distinct payment flows — card, bank transfer, and pay-on-site. And all of it had to run on a small seasonal farmstead's budget, with no paid mapping API and no expensive SaaS booking tools.
A booking system underneath WordPress, not a WordPress plugin bolted onto booking
"Upes Viesi Booking" is a custom plugin that handles its own data model, availability logic, pricing, legal documents, and GDPR — WooCommerce is only the payment pipeline, with no products in its catalog at all. All four client-facing wizards end at the same submission gateway.
All four client wizards submit data to the same uv_submit_booking handler, which re-checks availability server-side (against a race between parallel purchases), creates a WooCommerce order with a single fee line item, and branches into three paths depending on the chosen payment method. A change in payment status is the only signal that automatically generates the contract, invoice, and email — the admin calendar reads the same database, so a manual block and a real booking never collide.
Four booking paths, one database
Each service type gets its own step-by-step wizard with its own number of steps — but all of them write to the same tables, so a boat, the sauna, and a tent site never collide on the same date if they share inventory.
Flagship wizard
- 1. Route from the carousel or "Build your own"
- 2. Kayak / canoe + number of people
- 3. Dates — live availability
- 4. Delivery location (for your own route)
- 5. Boats with a DFS recommendation
- 6. Additional gear
- 7. Contact, contract, GDPR
- 8. Payment
The only wizard with a boat-allocation algorithm.
Relax wizard
- 1. Sauna or hot tub
- 2. Dates + delivery window
- 3. Delivery address, live price
- 4. Contact, contract, GDPR
- 5. Payment
route_id=0 drives the day-count on the invoice.
The simplest wizard
- 1. Tent site or apartment
- 2. Dates (nights)
- 3. Contact, contract, GDPR
- 4. Payment
No delivery step — just 4 of the 8 steps.
Hourly wizard
- 1. Gear selection
- 2. Date + time (30-min slots, 1-6h)
- 3. Contact, contract, GDPR
- 4. Payment
The only one with its own AJAX slot endpoint.
Seven systems that make a single booking trustworthy
Each of these pieces solves one specific, unglamorous problem — together they mean the owner never has to manually track whether something is double-booked, unpaid, or legally uncovered.
A boat-allocation algorithm that counts seats itself
For every requested day/hour combination, the system counts overlaps against wp_uv_booking_items using half-open interval logic (A < D AND B > C), including a 30-minute "soft hold" for bookings just created but not yet paid — so two people clicking "Pay" at the same moment can't end up with the same boat.
When the client enters the number of people, uv_suggest_boats() uses a DFS algorithm to search for the boat combination with the smallest leftover seat surplus — an exact match first, then the smallest "overshoot". The result comes back as a ready-made sentence for the client.
"We recommend: 2× Canoe + 1× Kayak. 1 seat free — no more compact boats available."
What else this engine does
- 120-day calendar precomputation — in a single DB query, works out which days can't fit the given number of people, and grays them out on the calendar.
- Alternative date search — if the chosen dates don't work, the system offers up to 5 nearby windows where everything requested is available.
- Packages resolve themselves — a package has no inventory of its own; it's blocked on any day one of its components is blocked.
- Hourly slots are separate math — SUP/clear-bottom boats are checked against a time window, not a date range, so they never conflict with day-based bookings.
WooCommerce as a payment pipeline, not a store
There isn’t a single WooCommerce product in this system. Every booking becomes one dynamically created fee line item ("Canoeing booking — Abava route, July 12–13, 4 people") on an order that’s fully restyled to match Upes Viesi’s visual identity — WooCommerce’s own emails and default look are switched off.
Three payment paths, three different rules: card (Stripe) confirms instantly, bank transfer (BACS) gets a 48-hour window before automatic cancellation, and pay-on-site is only allowed if the trip starts at least 14 days out — checked both on screen and server-side, so the rule can't be bypassed.
Three rules, one source
- 30 minutes — soft hold for a new, unpaid card booking, before it automatically releases the inventory.
- 48 hours — the transfer window; after that, the order and booking are canceled automatically by a cron job.
- 14 days — the minimum distance to the trip to allow pay-on-site; the same deadline triggers a reminder email.
- Manual cancellation before the payment page — the only moment the client hasn't yet been sent their "my booking" link, so the cancel button sits right on the payment page.
A tiered refund the client can request themselves
A refund table configurable in admin settings (defaults: 50% at ≥14 days, 25% at 7–13 days, 0% under 7 days) is applied automatically when the client uses their personal access link ("my booking") to request cancellation — no call to the owner needed.
Bookings that are confirmed but pay-on-site and not yet actually paid (on_site_paid = 0), automatically get a 0% refund — there's nothing paid in to refund. For card payments after payment, the self-service cancel button isn't shown at all, since the money movement is already real and goes through the payment processor.
Abuse protection
- Token access, not ID access — a booking can't be scanned for by guessing numbers.
- 5 attempts/h per IP — a rate limit on the self-service cancellation endpoint.
- Trip already started? — cancellation is no longer offered.
- Admin "Refund Preview" — shows the amount before the click, so staff know what to promise on the phone.
A legally binding contract that rewrites itself for every booking
The moment any booking is paid, the system generates a custom rental agreement — it includes only the safety clauses relevant to what was actually rented (the boat life-jacket clause disappears if only the sauna was rented), and splits the delivery fee using three different formulas depending on the order type.
In the publicly visible contract preview (before payment), the owner's personal ID number, address, and IBAN are replaced with a placeholder — the full data appears only in the server-generated, signed version. Invoice numbering (UV-2026-0047) uses an atomic MySQL counter per year, so two simultaneous purchases can never receive the same number.
Latvia-specific details
- VAT-exemption wording — added to the invoice automatically, since the service provider is self-employed.
- 5-year retention period — matches Accounting Law requirements; once it ends, a monthly cron anonymizes client data while keeping the financial rows.
- 6 legal pages auto-created on activation: privacy policy, cancellation and booking terms, 3 safety instructions (canoeing, sauna, hot tub).
- A full consent audit trail — contract version, IP address, and timestamp for every booking, visible in the admin detail view.
A custom calendar that never confuses "held" with "sold"
The admin calendar is written from scratch (no FullCalendar or other library) — multi-day bookings render as colored bars with lane allocation so they never overlap, and the month’s revenue total is counted in real time, excluding unpaid and abandoned sessions that used to give the owner the false impression a client had already paid.
A custom user role uv_manager with its own manage_uv_bookings permission lets the owner hire staff who manage bookings but never see plugins, users, or the product catalog.
Manual control when it's needed
- "Block inventory" — two clicks in the calendar reserve a boat for maintenance or personal use, using the same table the availability engine reads.
- "Mark as paid" for a BACS entry goes through the real WooCommerce
payment_complete()path — never straight to the DB — so the order and booking can never fall out of sync. - A Leaflet map in the detail view — start, end, and delivery points are visible on the map at a glance.
- One click copies the client's "my booking" link to read out over the phone.
12 email templates, an in-house map engine, and a WhatsApp bubble
Seven events (confirmed, awaiting transfer, awaiting pay-on-site, reminder, receipt, canceled, abandoned session) split into 12 templates — separate for client and admin — sharing one visual language: a dark header, a "torn ticket" perforated divider, and a route map embedded right in the email.
That map isn't from the Google Maps API — it's hand-written Web Mercator projection code that stitches together grayscale CARTO tiles and draws two colored dots on top. A deliberate choice to spare a small seasonal farmstead a paid mapping API bill.
Full channel coverage
- A tokenized "My Booking" page — downloads the contract/invoice and requests cancellation, with no login needed.
- WhatsApp bubble on every page — it adjusts itself automatically so it never overlaps the promo banner.
- An email QA console in admin — every one of the 12 templates can be previewed and a test email sent, showing the real trigger and attachments.
- A 30-day image cache for map snapshots — generated once, then served again from a file.
A route map drawn by clicking, not copying coordinates
Route, inventory, and package data are managed with ACF fields — but entering the start/end points in the admin editor has its own Leaflet map tool: staff click on the map (or paste a Google Maps link), and the system extracts the lat,lng coordinates itself, which the availability, email-map, and invoice logic then reads downstream.
Three shortcodes (route carousel, package promo cards, relaxation cards) and a custom homepage/blog system make up the rest of the public site, sharing a single Google Fonts load and one navigation/footer component across all the custom templates.
Small but sensitive details
- Promo banner automatically suppressed on the WooCommerce checkout and cart so it never conflicts with the payment flow.
- SVG icons in admin fields pass through
wp_kses()sanitization — editor freedom without XSS risk. - A custom-styled GTranslate language switcher — reworked from the default dropdown into an on-brand panel.
- A single font load for the whole site — a code comment notes that 8 files used to register the same font independently before.
Four cron jobs that work while the owner sleeps
None of these actions is manual. Each checks exactly one condition and acts only when it's true.
uv_check_abandoned_sessions
Finds abandoned wizard sessions after 30+ minutes of inactivity, sends the admin a summary.
uv_cleanup_abandoned
Cancels bookings stuck at pending_payment — 30 min for card, 48h for transfer — and releases the inventory.
uv_onsite_payment_reminder
Sends a reminder 14 days before the trip for an unpaid, pay-on-site booking.
uv_anonymize_bookings
Anonymizes client data for bookings older than the retention period; financial rows stay for accounting.
The work, in numbers
None of this is demo data — these are files and records sitting on the real production site.
28 functionality classes in the plugin.
Bookings, line items, abandoned sessions.
Routes, inventory, packages, documents.
Fully distinct, one shared core logic.
Card, transfer, on-site — different rules.
Across 7 event types, one visual system.
Auto-created and auto-refreshed on activation.
From abandoned-session detection to GDPR anonymization.
Other Transformations
| Project | Industry | Link |
|---|
Let's get to work. Let's start talking.
Tips and news about web development. Sign up for updates.
Submission received. Thank you!upesviesi.lv — live. This case study was prepared by researching the "Upes Viesi Booking" plugin's source code and the site's structure.
