EPA Booking Engine WP Registry grade A. No security issues foundWP RegistryNo security issues foundOpen the reportA
Generic WordPress booking engine for Shabbat meals, festivals, workshops and events. Built on WooCommerce.
Install
Upload it under Plugins → Add New Plugin → Upload Plugin, then activate.
Install straight from GitHub with WP-CLI:
wp plugin install https://github.com/elchanan-star/epa-booking-engine/releases/download/v1.7.1/epa-booking-engine-1.7.1.zipA modular WordPress plugin that powers a step-by-step booking flow. Built first for Shabbat meal reservations at a Tbilisi restaurant, architected to be reused for workshops, hotels, events and other bookable products.
File structure
epa-booking-engine/
├── epa-booking-engine.php Plugin bootstrap (headers, constants, requires)
├── assets/
│ ├── css/booking.css Front-end styling (dark luxury / gold theme)
│ ├── css/admin.css Admin settings page tab styling
│ ├── js/booking.js Booking flow logic (state, validation, Hebcal, REST submit)
│ └── js/admin.js Admin settings page tab switching
├── includes/
│ ├── class-plugin.php Singleton, settings reader, pricing, custom post type
│ ├── class-admin.php "EPA Booking" admin settings page (Settings API)
│ ├── class-assets.php Conditional CSS/JS enqueue (only on pages with the shortcode)
│ ├── class-shortcodes.php Renders [epa_booking_engine] markup
│ ├── class-rest.php REST endpoint that stores a submitted booking. Never touches WooCommerce.
│ ├── class-woocommerce.php WooCommerce order-lifecycle sync (cart pricing/display, order↔booking link, status sync)
│ └── class-checkout-controller.php The only place a booking is added to the WooCommerce cart — via a normal page load, not REST
├── assets/vendor/intl-tel-input/ Self-hosted phone input library (v18.5.3, MIT) — not loaded from a CDN
└── README.md
Both WooCommerce-facing classes are self-guarding: each no-ops entirely if WooCommerce is inactive.
Installation
- Copy the whole
epa-booking-enginefolder intowp-content/plugins/. - In WordPress admin, go to Plugins and activate EPA Booking Engine.
- Edit any page/post and add the shortcode:
[epa_booking_engine] - Publish the page. The booking form (CSS/JS) only loads on pages that contain this shortcode — it does not load site-wide.
Submitted bookings are stored under Bookings in the WordPress admin menu (a private post type, epa_booking), with meta fields for the Shabbat date, meals, guest counts, contact details and computed total.
If WooCommerce is active (and the configured product resolves — see the WooCommerce tab below), the booking is added to the WooCommerce cart with the server-calculated price and the visitor is redirected straight to WooCommerce Checkout. Payment, tax, coupons, order emails and refunds are entirely WooCommerce's — this plugin never touches any of that.
If WooCommerce is inactive, or misconfigured, nothing breaks: the booking is still saved with status pending_payment, and the visitor sees the original inline "thank you" placeholder instead of being redirected. The plugin never assumes WooCommerce exists.
Admin Settings Page
Everything below is controlled without touching code, from WordPress Admin → EPA Booking (left-hand menu, calendar icon).
The page has 5 tabs, all saved together under one option (epa_booking_engine_settings) when you click Save Settings:
| Tab | Controls |
|---|---|
| General Settings | Currency symbol, booking form title, success message, error message |
| Meals & Pricing | Per meal: enabled on/off, title, adult price, child price |
| Shabbat Settings | How many upcoming Shabbatot to show (1–10), Diaspora vs. Israel mode |
| Texts / Labels | Every step title, button label, guest/total/select labels, Step 5 field labels, summary row labels, the payment placeholder text, and all validation messages |
| WooCommerce | The WooCommerce product ID every booking is added to cart against (default 311, "Shabbat Reservation"). Shows a live status line: whether WooCommerce is active, and whether the configured product ID actually resolves |
Requires the manage_options capability (Administrator role).
How to change prices
EPA Booking → Meals & Pricing → edit Adult Price / Child Price for either meal → Save Settings. The new price is used immediately by the meal cards, the live summary total, and the server-side total recomputed on submit (the server never trusts a client-sent price).
How to enable/disable a meal
EPA Booking → Meals & Pricing → untick Enabled under that meal → Save Settings. A disabled meal disappears from the front-end meal cards entirely and is excluded from price calculation, both client- and server-side.
How to switch between Diaspora and Israel mode
EPA Booking → Shabbat Settings → Diaspora / Israel Mode → choose Diaspora or Israel → Save Settings. This flips the Hebcal i parameter (i=off for Diaspora, i=on for Israel) used when resolving Parasha names — see below.
How to change how many Shabbatot are shown
EPA Booking → Shabbat Settings → Number of Future Shabbatot (1–10) → Save Settings.
How to change texts
EPA Booking → Texts / Labels — every step title, button label, and validation message shown on the front-end lives here. No code or translation files needed for a simple wording change.
All settings fall back to sensible defaults if nothing has been saved yet (fresh install), so the plugin works out of the box.
Booking steps
The form is a 6-step flow: 1. Shabbat → 2. Branch → 3. Meals → 4. Diners → 5. Identification → 6. Summary.
- Branch (Step 2) is a simple single-select between the two configured branches (
tbilisi/batumiby default —EPA_Booking_Engine_Plugin::get_branches(), filterable viaepa_booking_engine_branches). Rendered server-side (no async fetch, unlike Step 1's Shabbat dates);booking.js'sbindBranchCards()handles the click/select interaction. - Diners (Step 4) is built dynamically by
booking.js(renderDinersStep()), not byclass-shortcodes.php— how many guest-count blocks to show depends on Step 3's selection, which doesn't exist yet at PHP render time. With one meal selected it's a single Adults/Children pair, identical to before this feature existed. With both meals selected, it renders one pair per meal (labeled with that meal's title), so a customer can bring a different number of guests to each sitting — e.g. 4 people Friday night, only 2 for Shabbat lunch. Each meal is priced against its own count.
On submit, the REST payload carries branch and a meal_guests array ([{meal, adults, children}, ...], one entry per selected meal) instead of flat adults/children fields. class-rest.php validates that every selected meal has at least one diner of its own, computes the total per meal, and — alongside the new branch and meal_guests post meta — still stores adults/children as the summed totals across meals, so everywhere else in the plugin that only ever needed "how many people total" (WooCommerce cart/order summary, the admin Guests column, the Reservation meta box's Adults/Children rows) keeps working unchanged. The Reservation meta box additionally shows a Guests per Meal breakdown row whenever a booking has more than one meal.
How the Shabbat dates are calculated
booking.js computes the next N upcoming Saturdays (N = Number of Future Shabbatot from settings, client-side, based on the visitor's browser date), then calls the public Hebcal API once per calendar year needed (with caching) to resolve the Parasha name for each date:
https://www.hebcal.com/hebcal?cfg=json&v=1&year=<YEAR>&i=<off|on>&maj=off&min=off&mod=off&nx=off&s=on
i=off is used in Diaspora mode (default, correct for Tbilisi), i=on in Israel mode — controlled by EPA Booking → Shabbat Settings. If the upcoming Shabbatot span a year boundary, both years are fetched automatically.
Phone field (intl-tel-input)
Step 5's phone field is powered by intl-tel-input v18.5.3, self-hosted under assets/vendor/intl-tel-input/ (not loaded from a CDN, so the field never depends on a third-party service being reachable). Default country is Georgia (ge); booking.js first tries a hint from the visitor's browser language (navigator.language, e.g. ka-GE → ge) with no IP lookup or external geolocation call, and falls back to Georgia whenever that isn't available. The visitor can always pick a different country from the dropdown.
On submit, state.phone is the E.164-formatted number (iti.getNumber(), e.g. +995...) and state.countryCode is the ISO 3166-1 alpha-2 code (e.g. GE), sent to the REST endpoint as phone and country_code and stored as their own post meta fields — country_code is intended for future reporting/WhatsApp/automation use. Validation (iti.isValidNumber()) blocks Step 5 from advancing with a friendly message (EPA Booking → Texts / Labels → Validation: Invalid Phone) if the number is incomplete or malformed for the selected country.
If the library script fails to load for any reason, every part of this degrades to the original plain-text-input behavior (iti stays null, checked at every call site) — the field keeps working, just without formatting/validation, and country_code is simply left empty. Existing bookings saved before this field existed have no country_code meta at all and continue to display and function normally (get_post_meta() on a missing key returns '', handled the same way everywhere it's already read).
WooCommerce integration
Two classes, two very different jobs. Neither is known to class-rest.php by name — it only ever asks the epa_booking_engine_checkout_url filter for a URL, and whoever currently answers that filter decides what happens next.
includes/class-checkout-controller.php— the only place a booking is ever added to the WooCommerce cart. Deliberately does not do this during the REST call. WordPress REST API requests never get a workingWC()->cart— WooCommerce's ownis_rest_api_request()check (matching any/wp-json/...URL, not just its own) skips cart/session bootstrap for all of them, by design. Fighting that with a manual session/cart bootstrap would mean depending on WooCommerce internals it explicitly chose not to expose here. Instead, this controller answers the checkout-URL filter with a signed link back to the same site, and only adds the item to the cart when the visitor's browser actually follows that link as a normal page load — the one context WooCommerce guarantees a working cart in.includes/class-woocommerce.php— everything from the moment the item is already in the cart onward: pricing it correctly, displaying the booking summary, and keeping the booking's own status in sync with the WooCommerce order once one exists.
Both self-guard identically: each registers its hooks only if function_exists( 'WC' ) is true at the moment it's instantiated (on plugins_loaded, by which point every active plugin — WooCommerce included — has already fully loaded). If WooCommerce is deactivated, both are entirely inert.
Flow, end to end:
- Step 6 submit →
class-rest.phpsaves theepa_bookingpost and computes the total server-side, exactly as before. It firesepa_booking_engine_booking_savedas a general "a booking exists now" signal (nothing currently listens for cart purposes), then asksepa_booking_engine_checkout_urlfor a URL. class-checkout-controller.phpanswers that filter — but only checks that the configured product exists; it does not touch the cart yet. It returns a signed URL back to the same site:home_url('/') + ?epa_booking={id}&epa_booking_token={nonce}.booking.jsredirects the browser to that URL instead of showing the inline confirmation (this part of the front-end is unchanged — it just follows whatevercheckout_urlit's given).- That request is a completely normal WordPress page load — not REST, not AJAX.
class-checkout-controller.phpintercepts it ontemplate_redirect, verifies the nonce, loads the booking back from its own post meta (the original REST request's data is long out of scope by now), and callsWC()->cart->add_to_cart(). This is guaranteed to work here, because a normal front-end page load is exactly the context WooCommerce's cart/session system is built for. - The controller immediately
wp_safe_redirect()s towc_get_checkout_url()and exits. The visitor never sees this intermediate page render. woocommerce_before_calculate_totals(inclass-woocommerce.php) overrides the cart line item's runtime price to the server-computed total. The product's own catalog price in the database is never touched.- Customer completes WooCommerce's own checkout form and payment. WooCommerce creates the Order natively — this plugin never calls
wc_create_order(). woocommerce_checkout_create_order_line_itemcopies the booking ID/reference onto the order line item (hidden meta) and the human-readable summary (Shabbat, meals, diners) as visible order item meta, so staff and the customer both see it on the order screen / order email.woocommerce_checkout_order_created(fires for both classic and block-based Checkout) reads the booking ID back off the order's own line items and stamps the bidirectional link:_epa_booking_id/_epa_booking_referenceon the Order,_wc_order_idon theepa_bookingpost. Booking status →order_created.- From here on, WooCommerce's own order status transitions drive the booking status:
| WooCommerce order status | Booking status | Hook |
|---|---|---|
pending, on-hold |
order_created |
woocommerce_order_status_changed |
processing, completed |
confirmed |
woocommerce_order_status_changed |
cancelled |
cancelled |
woocommerce_order_status_changed |
refunded |
refunded |
woocommerce_order_status_changed |
failed |
payment_failed |
woocommerce_order_status_changed |
Separately, payment_status post meta (unpaid → paid / refunded) is driven by woocommerce_payment_complete and woocommerce_order_refunded specifically — these fire at the exact moment money moves, which is a more precise signal than the general order-status change and lets you query "confirmed but not yet refunded" independently of the booking's lifecycle stage.
Every booking status change also fires epa_booking_engine_booking_status_changed( $booking_id, $old_status, $new_status ), regardless of what caused it — this is the hook for anything that should react to a booking being confirmed/cancelled (e.g. a future notifications feature) without needing to know WooCommerce exists.
HPOS: this plugin only reads/writes orders through wc_get_order() and WC_Order CRUD methods (get_meta(), update_meta_data(), get_items()) — never direct $wpdb queries against the posts table — and declares compatibility with WooCommerce's High-Performance Order Storage via before_woocommerce_init in epa-booking-engine.php.
For developers: where settings live in code
EPA_Booking_Engine_Plugin::get_settings()— the single source of truth; always returns a complete settings array (saved values merged over defaults viaarray_replace_recursive).EPA_Booking_Engine_Plugin::get_settings_defaults()— hard-coded defaults, used to seed a fresh install and to fill in any keys missing from an older saved option.EPA_Booking_Engine_Plugin::get_pricing()— derives the{currency, meals}shape from settings, already filtered to enabled meals only. Still runs through theepa_booking_engine_pricingfilter for backward compatibility with any code hooking into it directly.EPA_Booking_Engine_Plugin::get_upcoming_shabbatot_count()/get_shabbat_mode()— read the Shabbat tab.includes/class-admin.php— registers everything via the WordPress Settings API (register_setting,add_settings_section,add_settings_field), sanitizes all input, and renders the tabbed UI.- The front-end (
class-shortcodes.php,class-assets.php,booking.js) reads only through the methods above — nothing is hardcoded, so a future multi-site reuse of this plugin only ever needs the admin settings page, not code changes.
Known assumptions and limitations
- The cart is never touched during the REST call. This was a real bug in an earlier build of this integration (
WC()->cartis null inside any/wp-json/...request — WooCommerce skips cart/session bootstrap there on purpose) and is whyclass-checkout-controller.phpexists: it defers the actualadd_to_cart()call to the handoff page, a normal page load, where WooCommerce guarantees the cart works. - The handoff link is signed and single-use in intent.
?epa_booking={id}&epa_booking_token={nonce}— the nonce is scoped to that specific booking ID, so it can't be reused for a different booking. If the same link is opened again after checkout already happened (e.g. back button), the controller detects the booking already has a_wc_order_idand redirects straight to checkout without re-adding anything. - One booking per checkout. The handoff empties any existing cart contents before adding the booking. Deliberate v1 design choice matching the "1 booking = 1 order = 1 cart item" model — if multi-booking carts are ever needed,
add_booking_to_cart()inclass-checkout-controller.phpis the one place that changes. - Product 311 is never modified. Its catalog price, stock settings, etc. are left exactly as configured in WooCommerce. Only the cart line item's runtime price is overridden per booking (in
class-woocommerce.php). Worth manually verifying the product is set to Virtual (no shipping) in WooCommerce, since this plugin doesn't check or enforce that. epa_booking_engine_checkout_urlonly fires if WooCommerce was active at plugin bootstrap (function_exists( 'WC' )onplugins_loaded). If WooCommerce is inactive, or the configured product ID doesn't resolve to a real product, the filter is never registered / declines, and the front-end transparently falls back to the original inline "thank you" placeholder — nothing errors, nothing breaks.- Not yet built (still just architecture, same as before): coupons/discounts, seat availability limits, per-date pricing, translations. All hang off existing filters (
epa_booking_engine_pricing,epa_booking_engine_booking_status_changed) without touching this integration.
Releases
6 releases. Each count is every asset in that release; expand a row for the breakdown.