Westtro
Client Upes Viesi
Industry Tourism & Outdoor Recreation
Location Nīgrande, Saldus Municipality
Scope Custom booking plugin
Languages LV + GTranslate
Case study // Booking engineering

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.

LANGUAGES: LV + GTRANSLATE / 5 RENTAL TYPES, ONE INVENTORY
Upes Viesi logotips The logo and visual identity
were also designed by Westtro
UPES VIESI // NĪGRANDE, LV
01 // Context

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.

Canoeing
Routes
Canoes and kayaks on a set route or your own route, with delivery.
Relaxation
Sauna & hot tub
A mobile sauna and hot tub, delivered to the client's chosen location.
Overnight
Overnight stays
Tent sites and apartments, a separate flow, no delivery.
Hourly
SUP & boats
Half-hour slots, a fixed pickup location.
Bundled
Packages
Admin-assembled packages of several items at one price.
346 km

The length of the Venta — Latvia's longest river runs entirely within its borders.

178 km

of it navigable by boat within Latvia.

1

family runs everything — the system is built for their use, not an IT department's.

02 // The Challenge

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.

03 // The Solution

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.

status hook: on_order_paid / on_hold / cancelled 4 CLIENT WIZARDS Canoeing wizard route · boats (DFS) Sauna / hot tub wizard delivery address + time Overnight wizard nights, no delivery Hourly rental wizard 30-min slots, fixed location uv_submit_booking one AJAX gateway for all 4 wizards Availability check server-side, before the write wp_uv_bookings wp_uv_booking_items WooCommerce 1 fee line item (no products) status: pending_payment Stripe · card instant confirmation BACS · bank transfer 48h payment window On-site allowed if ≥14 days before the trip Contract + invoice HTML, auto-generated Email to client + admin Admin calendar reads the same table

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.

04 // Custom Flows

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.

Canoeing

Flagship wizard

  1. 1. Route from the carousel or "Build your own"
  2. 2. Kayak / canoe + number of people
  3. 3. Dates — live availability
  4. 4. Delivery location (for your own route)
  5. 5. Boats with a DFS recommendation
  6. 6. Additional gear
  7. 7. Contact, contract, GDPR
  8. 8. Payment

The only wizard with a boat-allocation algorithm.

Pirts / kubls

Relax wizard

  1. 1. Sauna or hot tub
  2. 2. Dates + delivery window
  3. 3. Delivery address, live price
  4. 4. Contact, contract, GDPR
  5. 5. Payment

route_id=0 drives the day-count on the invoice.

Overnight stays

The simplest wizard

  1. 1. Tent site or apartment
  2. 2. Dates (nights)
  3. 3. Contact, contract, GDPR
  4. 4. Payment

No delivery step — just 4 of the 8 steps.

SUP / clear-bottom boat

Hourly wizard

  1. 1. Gear selection
  2. 2. Date + time (30-min slots, 1-6h)
  3. 3. Contact, contract, GDPR
  4. 4. Payment

The only one with its own AJAX slot endpoint.

05 // Under the Hood

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.

01 Availability engine

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.
02 Payment orchestration

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.
03 Cancellation Policy

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.
04 Contracts, Invoices, GDPR

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.
05 Administration

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.
06 Communication

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.
07 Content Tools

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.
06 // Automation

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.

Every 5 min

uv_check_abandoned_sessions

Finds abandoned wizard sessions after 30+ minutes of inactivity, sends the admin a summary.

Every 5 min

uv_cleanup_abandoned

Cancels bookings stuck at pending_payment — 30 min for card, 48h for transfer — and releases the inventory.

Every day at 08:00

uv_onsite_payment_reminder

Sends a reminder 14 days before the trip for an unpaid, pay-on-site booking.

Once a month

uv_anonymize_bookings

Anonymizes client data for bookings older than the retention period; financial rows stay for accounting.

07 // By the Numbers

The work, in numbers

None of this is demo data — these are files and records sitting on the real production site.

55 PHP files

28 functionality classes in the plugin.

3 DB tables

Bookings, line items, abandoned sessions.

4 Post types

Routes, inventory, packages, documents.

4 Booking wizards

Fully distinct, one shared core logic.

3 Payment paths

Card, transfer, on-site — different rules.

12 Email templates

Across 7 event types, one visual system.

6 Legal pages

Auto-created and auto-refreshed on activation.

4 Cron automations

From abandoned-session detection to GDPR anonymization.

Index // Ledger

Other Transformations

ALL PROJECTS
Project Industry Link
Start a Project

Let's get to work. Let's start talking.

Updates

Tips and news about web development. Sign up for updates.

upesviesi.lv — live. This case study was prepared by researching the "Upes Viesi Booking" plugin's source code and the site's structure.