Skip to content

Manual reservations: admin-created bookings with pay-by-link and awaiting-payment holds - #226

Open
afonic wants to merge 65 commits into
mainfrom
feature/manual-reservations
Open

afonic wants to merge 65 commits into
mainfrom
feature/manual-reservations

Conversation

@afonic

@afonic afonic commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds manual (admin-created) reservations to Resrv: CP users can create a reservation on behalf of a customer, optionally send a pay-by-link email, and the reservation either confirms immediately (offline/no-charge) or waits in a new AWAITING_PAYMENT status until the customer pays online or the hold lapses.

Implements plan 009-manual-reservations (all tasks T1–T11).

What's included

Core flow

  • New AWAITING_PAYMENT reservation status with manual-reservation columns and a settings toggle
  • ManualReservationCreator service owning all quote/creation money math (base price, rates, extras incl. custom-amount extras, options, coupons, custom total)
  • CP create-reservation page (entry/date/property search, live quote with sequence-guarded fetches, extras/options with full fieldtype rendering, customer form mirroring checkout forms)
  • CP confirm/cancel actions for awaiting-payment rows, awaiting-payment surfaced in the CP index and show views

Pay-by-link

  • Customer pay page for manual reservations (resumes or mints a payment intent under a row lock, payability re-checked before handing out the client secret)
  • Payment request email with resend action; per-reservation return URLs threaded to redirect gateways (optional 4th arg to paymentIntent())
  • resrv:cancel-lapsed-holds command: auto-cancels holds past their deadline, restores stock, notifies admin/customer; lazy lapse sweep with a lock re-check

Payment hardening

  • Two-phase, row-locked intent writes; verify-before-clear/re-mint on payment_id; gateway stamped atomically with the intent
  • Rejects webhook-less online gateways, removed gateways, and zero/invalid custom amounts; nonzero manual rows always carry a validated gateway
  • Out-of-band confirm vs. captured charge and duplicate-payment cases now send explicit admin notifications instead of silently double-charging or stranding money

BC notes

  • retrievePaymentIntent() signature change is a documented BC break for gateway addons (see UPGRADE-PAYMENT-GATEWAYS.md); legacy 3-arg gateway shim removed
  • Event-driven lifecycle preserved: all side effects (stock, emails, affiliates) go through listeners

Review history

The branch has been through 18 rounds of internal + external review; each round's confirmed findings were fixed in dedicated commits (see commit log). Refuted findings are documented inline where the code looks suspicious on purpose.

Test plan

  • Full suite green (SQLite): unit/feature tests cover the creator service, CP HTTP layer, pay page, lapse command, webhook paths, and race conditions (TOCTOU patterns via Reservation::retrieved() hooks and DB-level updates)
  • PostgreSQL suite: known pre-existing baseline failure only (DynamicPricingCpTest)
  • Remaining manual step: Stripe end-to-end pass on a live test key (T11)

afonic added 30 commits July 5, 2026 22:19
Dates now use the DateRangePicker (or a single DatePicker when the
maximum reservation period is one day, booking date to date + 1 like
the frontend), the entry select is a searchable Combobox with a
collection badge, and the page adopts the Statamic 6 idioms: Header
with actions, card section headings, required-field indicators,
currency-prefixed money inputs, and a highlighted amount panel. The
controller passes the reservation-period and minimum-days-before
config so the page can pick the right datepicker and minimum date.

Grids avoid pairing a base grid-cols-1 with responsive variants: the
core CP ships unlayered utilities that override the addon's layered
ones, which is why the old md:grid-cols-4 row never applied.
…umbing

Custom amount (the reported bug): the live quote no longer requires
custom_amount, so choosing "custom" before typing an amount keeps the
pricing/payment section on screen instead of 422-ing and blanking it —
the amount stays required at store time. The Create page keeps the last
good quote on any quote error (so the section never disappears) and
validates the custom amount client-side against the total, surfacing the
message under the field and pointing at the "Override total" toggle to
charge more.

Shared plumbing consolidated (no behaviour change):
- HandlesCustomerLookup trait: deep-link resolution + two-bucket rate
  limiting shared by the status and pay-by-link pages.
- Reservation::amountDue(), paymentGatewaySupportsManualConfirmation(),
  isAwaitingPayment(), and static resolveCustomerPageEntry() as single
  owners, used by the Livewire pages, payment-request mail, and CP.
- ManualPaymentMode enum replaces the payment-mode string literals.
- Lapsed-hold / awaiting-payment intent cancellation moves into
  ReservationRefundProcessor::cancelWithoutRefund(cancelOpenIntent:), and
  the admin notification into the SendHoldLapsedAdminEmail listener.
- forCp() stops exposing surcharge/limits (amounts come from the quote);
  customerPaymentUrl() and the index payment_url gate on awaiting-payment.
Redirect gateways (e.g. Mollie) baked the shared checkout-complete entry into
the provider payment, so a customer paying a manual reservation was bounced
there instead of the pay-by-link page. Pass the return-URL base as an optional
4th positional arg to paymentIntent(): the PaymentInterface stays at 3 params so
existing 3-param gateways keep working (PHP ignores the extra arg) — declaring
the 4th on the interface would fatal them ("Declaration must be compatible").

- Checkout::initializePayment passes the checkout-complete entry (normal
  checkout unchanged); the manual pay page passes customerPaymentUrl().
- ReservationPayment consults the gateway's handleRedirectBack() on return for
  interim processing/failed messaging; the webhook stays the confirmation truth.
- Bundled Stripe/Fake/Offline gateways declare the ignored 4th param to model
  the convention; documented as Step 12 in UPGRADE-PAYMENT-GATEWAYS.md.
- Treat AWAITING_PAYMENT holds as in-flight so absolute CP inventory edits are
  not corrupted when the hold later releases; related manual-creation and
  refund-processor hardening plus tests.
…l email, zero-amount

Address four pre-ship review findings on the manual-reservations flow.

F1 (P1): Confirming an online-gateway awaiting-payment reservation out of band
(paid in person) left it CONFIRMED with an online gateway and an empty/voided
payment_id, so a later refund/cancel routed to the gateway with a dead intent and
failed — stranding the booking. Reconcile on confirm: ReservationRefundProcessor::
settlePaidOutOfBand() voids the open intent and clears payment_id, but only when the
intent holds no captured money (a racing webhook charge is kept refundable). Refunds
now skip the provider for a CONFIRMED row with an empty payment_id
(Reservation::confirmedWithoutGatewayCharge()), landing REFUNDED like an offline
booking. Gateway-agnostic — does not depend on an 'offline' gateway being registered.

F2 (P2): Custom-priced extras priced at x1 in the CP because priceExtras() dropped
the customer payload that Extra::getCustomPrice() reads. Pass it through; convert a
missing/non-numeric custom-field throw into a 422 instead of a 500.

F3 (P2): Cancelling an unpaid awaiting-payment hold emailed the customer that their
payment was non-refundable, though nothing was captured. New
ReservationCancelled::CONTEXT_UNPAID_HOLD routes the email to "nothing to refund".

F4 (P2): Zero-amount bookings could not be submitted when only online gateways were
configured without a payment page. Make the request gateway nullable and enforce
"gateway required for a non-zero amount" in create() via a CP-only flag (keeps the
creator's direct-caller contract); allow the Create button to submit at zero amount.

Adds coverage in AwaitingPaymentTransitionsTest, ManualReservationCreatorTest, and
ReservationCancelledCustomerTest; FakePaymentGateway gains refundCalls/
retrievedIntentStatus test hooks. Rebuilt CP assets.
Payment/refund edge cases surfaced in pre-ship review, all validated:

F1 (money): an online-gateway manual booking confirmed out of band (empty
payment_id) was self-service refundable, landing REFUNDED with no money moved.
supportsAutomaticRefund() now returns false for confirmedWithoutGatewayCharge()
so it routes to manual handling; the CP admin refund still settles it.

F2 (money): pay-link intent creation wrote payment_id without re-checking
payability, and Cache::lock only serializes concurrent pay() calls — a cancel/
confirm on a separate DB row lock could commit during the gateway round-trip.
resolveOrCreateIntent now re-verifies a caller-supplied payability guard under a
row lock in the same critical section as the write, and voids the orphan intent
+ throws ReservationNoLongerPayable on abort.

F3 (money): settlePaidOutOfBand cleared payment_id even when the intent cancel
silently failed. It now re-reads the intent and clears only when verifiably
canceled/gone, keeping the reference (for gateway refund) otherwise.

F4 (docs): document retrievePaymentIntent() as a required interface method
(new Step 13 + checklist), matching the project's deliberate fatal-at-boot
convention rather than adding a method_exists shim that would silently defeat
the double-charge/stranded-charge guards.

F5 (consistency): view-only manual holds (affects_availability=false) leaked
into shared-rate max_available capacity. Added the affects_availability filter
to the three parent-Reservation capacity queries.

FakePaymentGateway now models a cancel moving the intent to 'canceled'
(+ cancelSucceeds toggle and an onPaymentIntent race hook) so the tests are
faithful. Adds regression coverage for all five findings.
CANCELLED is also reachable from CONFIRMED, so a webhook or concurrent
CP confirm landing between cancelAwaitingPayment's pre-check and
transitionTo()'s row lock could cancel a PAID booking with the
unpaid-hold wording. Add the same in-transaction origin re-check
CancelLapsedHolds already documents as load-bearing, plus a regression
test that simulates the race via a Reservation::retrieved() hook.
resolveOrCreateIntent's pre-lock fast path resumed a stored intent
without the locked refresh/payability re-check, so a CP cancel,
out-of-band confirm, or hold-lapse sweep committing after pay()'s
outer guard could hand the customer a chargeable secret for a
no-longer-payable booking. Resume now rides the per-reservation
intent lock, and the resumed intent is re-verified against a fresh
row right before being handed out — the resume analogue of the mint
path's row-locked re-verify. No void on failure: the transition owns
the stored intent's disposal (settlePaidOutOfBand deliberately keeps
a capturing intent's reference).

Adds FakePaymentGateway::$onRetrievePaymentIntent so tests can land
a transition during the resume round-trip, plus regression tests for
both race windows.
… fallback

The admin cancellation email gated its "No refund issued — payment
retained" panel on payment_id alone, so a lapsed hold whose customer
opened the pay link (leaving a voided, never-captured intent id in
payment_id) told the business it kept money that was never collected.
Mirror the customer mailable's paymentCollected flag, which excludes
the hold-lapsed and unpaid-hold contexts.

In the CP create page, fetchQuote()'s error fallback chained ?? after
Object.values(...).join(' '), which yields '' (not nullish) when the
failure carries no structured errors — leaving quoteError empty, the
stale quote on screen, and submission unblocked. Guard the joined
validation string with || so the fallback message is reachable.
…t credentials, dead pay links

- Raise the per-reservation intent lock TTL from 15s to 300s so it outlives
  the whole critical section (up to three provider round-trips at Stripe's
  default 80s client timeout); an expiring lock let a concurrent pay() mint a
  duplicate chargeable intent that the first request's write then untracked.
- Treat a requires_capture intent as money in flight in resumeExistingIntent,
  matching the Step 13 status contract and settlePaidOutOfBand: a held
  authorization now shows the processing state instead of being remounted or
  void-and-replaced (which would release secured money and re-ask for it).
- Require the mount credential on resumed inline intents: a Step-13
  spec-minimum retrieve (status only) is voided and re-minted instead of
  rendering a payment form with an empty client_secret; documented the
  ->client_secret guidance alongside the existing ->redirectTo rule.
- Refuse to (re)send an online payment request when the payment page entry is
  gone (422 with a clear message, no false sent-stamp), and make the template
  fall back to contact-us copy instead of bank-transfer wording when an
  online reservation has no pay link.
cancelWithoutRefund(cancelOpenIntent: true) — the lapse sweep and the CP
cancel-awaiting action — voided the customer's opened-but-unpaid intent at the
gateway but left its id in payment_id. Every payment_id reader then treated
the never-paid CANCELLED row as one that collected money: the customer status
page showed "Cancelled — no refund issued" and a non-zero "Amount paid"
(hasGatewayPayment()/amountPaidOnline()) for a booking whose cancellation said
no money was collected.

Mirror settlePaidOutOfBand()'s verify-before-clear instead of clearing
blindly: void the intent, re-read it, and drop the local reference only once
the gateway confirms it can no longer take money. When the void cannot be
verified (provider brownout, or a racing webhook actually captured the
money) the reference is kept for reconciliation — never discard the only
handle on a charge that may still be live.
- Limit the overbook toggle to stock bypasses: availability status also reads
  false for structural reasons (disabled entry, unpublished or inapplicable
  rate, rate date-window/stay/lead-time restrictions) and the pricing lookup
  underneath is validity-blind, so affects_availability=false could book an
  entry or rate the create form never offers. A structural re-check now
  rejects those while still allowing genuine overbooks (stock count and
  shared-rate capacity stay bypassable — that is what the toggle is for).
- Scope manual options to published ones, mirroring the create form's
  optionsForEntry() filter and the existing extras check: a stale or crafted
  payload could price and attach an option unpublished after the form loaded.
- Enforce the create page's affiliate visibility on store: reject affiliate_id
  when the feature flag is off or the affiliate is unpublished, instead of
  attaching commission rows for affiliates the UI intentionally hides.
…o-sweep window

paymentGatewaySupportsManualConfirmation() maps an UNKNOWN gateway to false,
so a reservation whose recorded gateway was removed from payment_gateways
slipped past the pay-link guard as "online": the request emailed a "Pay now"
link that could only error when the page failed to resolve the gateway, and
stamped the send. sendPaymentRequestEmail() now validates the recorded
gateway resolves first (blank still falls back to the default per
resolvePaymentGateway's legacy rule) and refuses with a clear 422 otherwise.

Also document on ReservationPayment::deadlinePassed() why hold expiry guards
only NEW payment attempts: an intent mounted before the deadline stays
completable until the sweep voids it, and honoring a payment in that window
confirms a booking whose stock is still decremented — strictly better than
orphaning the charge. Post-sweep webhooks already stay CANCELLED and notify.
…CP fieldtypes

Address three pre-ship review findings on the manual-reservations CP flow.

F1 (P1): The live quote deliberately omitted the customer payload, so a
custom-priced extra (price × a checkout-form field like adults) previewed
at ×1 while create() recalculated with the submitted customer data — the
admin could approve a total that was never stored. The quote request now
accepts and carries the customer payload, the create page sends it and
re-quotes when a driving field of a selected custom extra changes (the
entry payload exposes each extra's custom handle so unrelated fields don't
trigger quotes), and a selected custom extra whose driving field is still
empty fails the quote with the same 422 creation would raise — blocking
submission instead of previewing an amount creation would not charge.

F2 (P2): Overlapping quote requests could resolve out of order, letting a
slow older response overwrite quote/extras/options with stale data that
submission then ran against. fetchQuote() now carries a sequence counter;
only the latest request may write state, surface errors, or clear the
spinner. Money was never at risk — the server recomputes at store time —
but the reviewed figures could diverge from the stored ones.

F3 (P2): The customer card rendered radio, toggle, integer, and dictionary
checkout fields as plain text inputs, producing malformed value shapes for
forms that work on the frontend. Render each with a compatible control
(radio group, boolean Switch, number input, item Combobox; the
country_phone_codes dictionary stays a free-typed tel input to match what
the frontend stores), resolve dictionary items server-side in the entry
endpoint, and normalize all three Statamic option config shapes (assoc
map, {key, value} rows, plain list) when building select-style options.
…ed holds, gateway stamping

Address three round-7 P2s and one round-8 P1 on the manual-reservations
payment flow.

Verify the void before re-minting (P2): a superseded intent's credentials
were already handed to the customer (an earlier tab or the provider's
hosted page can still complete it), and StripePaymentGateway's
cancelPaymentIntent swallows API errors — so void-and-remint could leave
two chargeable intents for one reservation. voidSupersededIntent() now
cancels, re-reads the intent, and gates the mint on the provider's answer:
absent/canceled proceeds, money already moving surfaces the processing
state, anything still live aborts with a retryable error and no
replacement. The gateway-upgrade guide documents the retrievePaymentIntent
contract this relies on.

Pay page resolves blank gateways like the email does (P2): the payment
mount and redirect-return paths used the manager's forReservation(), which
throws on a blank recorded gateway — yet the payment-request email path
falls back to the default and happily offers a pay link. Both now resolve
through Reservation::resolvePaymentGateway(), so legacy/programmatic rows
with no recorded gateway get the default instead of a guaranteed error.

Refuse payment requests for lapsed holds (P2): the lapse sweep may not
have cancelled an expired hold yet, but the pay page already refuses
past-deadline links — emailing "Pay now" with a dead link could only
dead-end the customer. sendPaymentRequestEmail() now throws, sharing the
new Reservation::holdDeadlinePassed() with the pay page's own check.

Stamp the minting gateway onto blank-gateway rows (P1): an intent minted
through the default gateway for a reservation with a blank recorded
gateway left payment_gateway empty, so the cancel/lapse void no-ops
stranded the live intent at the provider. The locked two-phase intent
write now stamps PaymentGatewayManager::defaultName() into
payment_gateway atomically with payment_id, so every later reader (voids,
out-of-band reconcile, refunds) reaches the provider the intent actually
lives at — even if the configured default changes first.
Tests point Blueprint::setDirectory() at resources/blueprints so the
shipped settings/checkout blueprints resolve, but CreatesEntries::
makeBlueprint() then saves collection blueprints into that same real
directory — PreventsSavingStacheItemsToDisk only fakes stache stores,
and blueprints are a file-backed repository outside the stache. The
resulting collections/pages/pages.yaml is regenerated on every fresh
test run: an artifact, not source.
…ion values

- ManualReservationCreator::quote() no longer abandons the admin's own
  in-progress frontend checkout: it prunes stale holds via
  ExpireReservations(expireSessionHold: false) — the same CP-safe pattern
  AvailabilityCpController uses — instead of getAvailabilityForEntry's
  frontend default, which expires session('resrv_reservation') outright.

- Manual creation rejects option values that were soft-deleted after the
  create form loaded (or submitted directly): Option::calculatePrice()
  resolves values withTrashed() for historical repricing, so new
  reservations now verify the value is still on offer before pricing.

- Same guard applied to the frontend checkout (validateExtraCharges and
  the parent path) via Reservation::activeOptionFor(), which also turns
  the previously-fatal null Option::find() on a bogus/trashed option id
  into an OptionsException the checkout already surfaces. Historical
  repricing paths (extraCharges) keep withTrashed() on purpose.
OptionValue::published is a CP-managed toggle, but the manual flow only
excluded soft-deleted values: optionsForEntry() listed draft values in
the create form's picker, and the priceOptions() guard accepted them on
quote/store. Constrain the listing's values eager-load to published and
require published in the server-side guard, so a draft value can neither
be offered nor booked.

The frontend checkout has the same pre-existing gap (it displays and
accepts unpublished values); that is left for a separate branch since
tightening validation there without fixing the display would reject
values the checkout legitimately shows customers.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a full “manual reservations” workflow to Resrv, enabling CP admins to create bookings on a customer’s behalf, optionally email a pay-by-link, and manage a new awaiting-payment lifecycle including lapsed-hold cancellation and safer payment-intent resumption.

Changes:

  • Introduces awaiting_payment status plus new reservation columns (affects_availability, created_by, hold_expires_at, payment_request_email_sent_at) and CP UI/actions for confirm/cancel/resend payment request.
  • Extends the payment-gateway contract for pay-by-link + intent resumption (paymentIntent(..., ?string $returnUrl) and retrievePaymentIntent(...)) and hardens intent lifecycle handling.
  • Adds a customer pay-by-link page (Livewire) and a scheduled command (resrv:cancel-lapsed-holds) with updated customer/admin notifications.

Reviewed changes

Copilot reviewed 89 out of 96 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
UPGRADE-PAYMENT-GATEWAYS.md Documents gateway BC breaks and new required gateway capabilities for return URLs and intent retrieval.
RELEASE-v6.1.0.md Release notes describing manual reservations and gateway contract changes.
.gitignore Ignores blueprint artifacts created during tests.
phpunit.xml Adds ManualReservation testsuite directory.
phpunit.pgsql.xml Adds ManualReservation testsuite directory for PG runs.
database/migrations/2026_07_05_000000_add_manual_reservation_columns_to_resrv_reservations_table.php Adds manual-reservation columns to resrv_reservations.
resources/blueprints/settings.yaml Adds settings for manual reservation payment page + email event options.
resources/dist/build/manifest.json Updates built asset hashes.
resources/lang/en/frontend.php Adds frontend strings for the payment page.
resources/views/livewire/reservation-payment.blade.php New pay-by-link page UI.
resources/views/email/reservations/payment-request.blade.php New customer payment-request email template.
resources/views/email/reservations/orphaned-payment.blade.php Extends orphaned-payment email with reconciliation contexts.
resources/views/email/reservations/cancelled.blade.php Adds hold-lapsed wording + correct “payment collected” logic.
resources/views/email/reservations/cancelled-customer.blade.php Adds hold-lapsed wording + correct “payment collected” logic.
resources/js/cp.js Registers the new CP “Create reservation” page.
resources/js/pages/Reservations/Index.vue Adds awaiting-payment badge/actions and create-reservation entrypoint.
resources/js/pages/Reservations/Show.vue Adds awaiting-payment action card and modals + payment-link copy.
resources/js/pages/Reservations/Create.vue New CP page to create manual reservations (entry/date search, quote, submit).
resources/js/composables/useReservationDisplay.js Shared status formatting and payment-link copy helper.
resources/js/components/ConfirmPaymentModal.vue Confirmation modal for manual “confirm payment”.
resources/js/components/CancelAwaitingPaymentModal.vue Confirmation modal for manual “cancel awaiting payment”.
routes/cp.php Adds CP routes for manual reservation create/quote/store + awaiting-payment actions.
src/Enums/ReservationStatus.php Adds AWAITING_PAYMENT and transition rules + in-flight membership.
src/Enums/ManualPaymentMode.php Adds enum for manual payment-mode selection.
src/Enums/ReservationEmailEvent.php Adds customer_payment_request email event.
src/Enums/ReservationLogReason.php Adds CP confirmation log reason.
src/Events/ReservationCancelled.php Adds cancellation context to drive email wording.
src/Events/ReservationConfirmed.php Adds VIA_CP confirmation source.
src/Exceptions/ManualReservationException.php New domain exception for manual reservation flow.
src/Exceptions/ReservationNoLongerPayable.php New exception for TOCTOU payability invalidation on pay page.
src/Data/ReservationData.php Adds flags for CP-driven flows and dynamic-pricing skipping.
src/Http/Requests/QuoteManualReservationRequest.php Validates quote request shape for CP manual quoting.
src/Http/Requests/StoreManualReservationRequest.php Adds store-time validation incl. merging checkout-form rules.
src/Http/Controllers/ManualReservationCpController.php Implements CP endpoints for entries/rates/form fields/quote/store.
src/Http/Controllers/ReservationCpController.php Adds awaiting-payment actions (confirm/cancel/resend payment request) and serialization fields.
src/Http/Controllers/ExportCpController.php Includes awaiting_payment in exportable statuses.
src/Filters/ReservationStatus.php Adds awaiting_payment to CP status filter.
src/Models/Reservation.php Adds manual-reservation fields/casts/helpers and customer pay/status link resolution.
src/Resources/ReservationResource.php Exposes affects_availability and conditional payment_url in API/CP listing payloads.
src/Support/ReservationEmailConfigResolver.php Adds default config for customer payment-request email event.
src/Support/ActiveReservationsGuard.php Treats awaiting-payment (affecting availability) as in-flight for CP inventory edits.
src/Repositories/AvailabilityRepository.php Excludes affects_availability=false holds from shared-rate capacity calculations.
src/Livewire/ReservationPayment.php New Livewire pay-by-link component (lookup, resume/mint intent, redirect handling).
src/Livewire/ReservationStatus.php Refactors customer lookup/rate limiting via shared trait.
src/Livewire/Checkout.php Threads checkout-complete URL into gateway paymentIntent() returnUrl parameter.
src/Livewire/Traits/HandlesCustomerLookup.php New shared deep-link lookup + rate-limiting trait.
src/Livewire/Traits/HandlesDirectGatewayPayment.php New shared “direct payment” trait for non-checkout payment surfaces.
src/Livewire/Traits/HandlesAvailabilityQueries.php Excludes view-only holds from shared-rate cap validation.
src/Http/Payment/PaymentInterface.php Adds retrievePaymentIntent() and paymentIntent(..., $returnUrl) contract updates.
src/Http/Payment/PaymentGatewayManager.php Adds default gateway name accessor + CP-friendly gateway listing.
src/Http/Payment/StripePaymentGateway.php Implements new interface signature + retrievePaymentIntent() semantics.
src/Http/Payment/OfflinePaymentGateway.php Implements new interface signature + no-op retrievePaymentIntent().
src/Http/Payment/FakePaymentGateway.php Extends test gateway with intent lifecycle tracking + retrieval semantics.
src/Providers/ResrvProvider.php Registers lapsed-hold command and admin hold-lapse email listener.
src/Providers/ResrvLivewireProvider.php Registers the new reservation-payment Livewire component.
src/Console/Commands/CancelLapsedHolds.php New command to cancel overdue awaiting-payment holds with safe state re-checks.
src/Support/ReservationRefundProcessor.php Adds intent-void + reconciliation logic, cancellation context, and out-of-band settlement handling.
src/Listeners/DecreaseAvailability.php Skips stock decrement for affects_availability=false.
src/Listeners/IncreaseAvailability.php Skips stock restore for affects_availability=false.
src/Listeners/AddReservationIdToSession.php Prevents CP-created reservations from leaking into frontend checkout session.
src/Listeners/AddDynamicPricingsToReservation.php Skips dynamic-pricing pivot writes when totals are manually overridden.
src/Listeners/CancelAffiliateCommission.php Cancels commission for unpaid-hold cancellations even if payment_id lingers.
src/Listeners/LogReservationConfirmed.php Distinguishes CP confirmation logging from checkout/webhook.
src/Listeners/SendHoldLapsedAdminEmail.php Sends admin cancellation email for hold-lapsed cancellations.
src/Listeners/SendCustomerCancelledEmail.php Passes cancellation context through to queued customer cancellation email job.
src/Jobs/SendCustomerCancelledEmail.php Backward-compatible handling of queued payloads predating the new context field.
src/Mail/ReservationPaymentRequest.php New mailable for customer payment request.
src/Mail/ReservationCancelled.php Adds context-aware wording + correct payment-collected gating.
src/Mail/ReservationCancelledCustomer.php Adds context-aware wording + correct payment-collected gating.
src/Mail/OrphanedPaymentNotification.php Adds reconciliation contexts and includes CANCELLED as an orphanable terminal state.
tests/Support/WebhooklessOnlineGateway.php Test double for “online but no webhooks” gateway validation.
tests/Support/FakeRedirectGateway.php Test double for redirect-gateway returnUrl behavior and redirect-back status mapping.
tests/Settings/SettingsDefaultsParityTest.php Ensures new setting key exists with defaults.
tests/Rate/RateSharedAvailabilityTest.php Ensures view-only awaiting-payment holds don’t consume shared-rate caps.
tests/Availability/AvailabilityCpTest.php Blocks absolute availability edits only for holds that affect availability.
tests/Livewire/CheckoutOptionsTest.php Rejects soft-deleted option values submitted after options step load.
tests/Livewire/ReservationStatusTest.php Ensures cancelled unpaid holds don’t display as “payment retained”.
tests/Payment/PaymentGatewayManagerTest.php Verifies interface signature includes returnUrl parameter.
tests/Payment/MultiplePaymentCheckoutTest.php Asserts normal checkout passes checkout-complete URL as gateway returnUrl base.
tests/Payment/StripePaymentGatewayTest.php Tests Stripe retrievePaymentIntent() null-vs-throw behavior.
tests/Mail/ReservationCancelledCustomerTest.php Tests context-aware cancellation copy and legacy queued-job payload behavior.
tests/Affiliate/AffiliateCommissionCancellationTest.php Ensures unpaid-hold cancellations void affiliate commission.
tests/ManualReservation/ReservationStatusEnumTest.php Tests awaiting_payment transition rules and manual-reservation columns.
tests/ManualReservation/PaymentRequestEmailTest.php Tests payment-request email sending, resend, and safety guards.
tests/ManualReservation/ManualReservationCpTest.php Tests CP endpoints for manual reservation flow, quoting, and validation.
tests/ManualReservation/CancelLapsedHoldsTest.php Tests lapsed-hold cancellation command behavior and races.
tests/ManualReservation/AvailabilitySymmetryTest.php Tests affects_availability symmetry (no decrement implies no restore).
tests/ManualReservation/ManualReservationCreatorTest.php Tests pricing/creation logic owner for manual reservations.
tests/ManualReservation/ReservationPaymentPageTest.php Tests pay-by-link page behavior and intent resume/mint flows.
tests/ManualReservation/AwaitingPaymentTransitionsTest.php Tests awaiting-payment transitions and related intent handling.
Files not reviewed (1)
  • resources/frontend/css/resrv-tailwind.css: Generated file

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +24 to +28
/**
* Fetch a previously created payment intent so an interrupted payment can resume
* without creating (and potentially double-charging) a second intent. Return null
* when the intent cannot be retrieved — callers then create a fresh one.
*/
Comment on lines 1068 to 1073
$optionsCost = Price::create(0);
if (array_key_exists('options', $data) > 0) {
$data['options']->each(function ($option) use ($data, $optionsCost) {
$optionsCost->add(Option::find($option['id'])->calculatePrice($data, $option['value']));
$optionsCost->add($this->activeOptionFor($option)->calculatePrice($data, $option['value']));
});
}
afonic added 28 commits July 12, 2026 19:58
When retrievePaymentIntent() threw during either verification read in
settlePaidOutOfBand(), the method only logged and kept the charge
reference. The intent's state is unknown in those paths — it may have
captured money or may still be completable with the client secret the
customer holds — and if the customer completes it later, the succeeded
webhook sees CONFIRMED and no-ops, so the duplicate payment would
produce no signal beyond a log line.

Both exception paths now dispatch the OrphanedPaymentNotification with
a new CONTEXT_OUT_OF_BAND_UNVERIFIED context telling admins to check
the intent in the gateway dashboard, matching the notify-on-last-
visible-point invariant the verified duplicate/still-payable branches
already follow.
…ations

# Conflicts:
#	resources/dist/build/assets/cp-BqLRhA-a.js
#	resources/dist/build/manifest.json
v6.1.0 through v6.3.0 already shipped without this feature.
…bhook

The pre-check saw AWAITING_PAYMENT, so a no-op transition means a webhook
confirm won the race between the check and the row lock. The winning webhook
no-ops its orphan detection on rows it confirmed itself, and the admin's click
still claims money arrived out of band — so run settlePaidOutOfBand on the
committed row instead of returning a 200 that hides the double payment.
Extra::getCustomPrice falls back to session('resrv-search')->customer when no
customer payload is passed, so an admin's own frontend search could skew a CP
quote's custom-extra pricing before the customer form is filled. Generalize the
coupon stash to withoutCheckoutSession covering both keys.
The resend action emails the customer, so it now goes through the same
confirmation modal as the confirmation resend. The new int-typed {id} routes
get whereNumber so a non-numeric id 404s instead of a TypeError 500.
Compress every comment the branch added to the minimum that keeps its
load-bearing content: invariants, ordering and race constraints, cross-file
contracts, and justifications for deliberately odd-looking code. Net -555
comment lines; no code changes.
- Notify admins when an out-of-band confirm finds its gateway removed
- Stamp payment_unresolved on retained cancel-path references and
  exclude them from the retained-revenue set in reports
- Disable create-form gateways the store request would reject
  (webhook-less online, no payment page, amount outside limits)
- Use gateway-neutral confirmation wording when no money moved online
…ved references

- cancelWithoutRefund reconciles the open intent before dispatching, so a
  capture that raced the cancellation swaps the unpaid-hold wording for
  CONTEXT_PAYMENT_IN_FLIGHT and alerts admins immediately (deduped against
  the webhook's orphan detection)
- a removed gateway now sends a reconciliation alert on cancellation too:
  its webhook route 404s, so orphan detection can never fire there
- amountPaidOnline and the status-page label no longer present an
  unresolved payment reference as collected money
… confirm

An omitted custom amount on a zero total is a supported comped booking, but
the store request's required_if rejected it before the creator's relaxed
check could run and Create.vue blocked submit the same way; the rule is now
nullable and the creator stays the sole enforcer for nonzero totals.

settlePaidOutOfBand treated requires_capture as money-moving and kept the
authorization open with only a duplicate warning, while the cancel path
already voided it; the pre-void check now covers succeeded/processing only,
so an uncaptured hold is voided (post-void verify still catches a capture
racing the void).
Refunding an out-of-band or offline payment lands REFUNDED without a
gateway call by design, but the CP showed the same "Reservation refunded"
toast as an automatic refund; the endpoint now returns refund_is_automatic
(from hasGatewayPayment() captured pre-transition) so the toast tells the
admin to return the funds by hand.

hasActiveReservationsForRange() blocked availability deletion on view-only
(affects_availability=false) reservations even though they never wrote a
hold key and nothing can be orphaned; it now applies the same flag filter
as the in-flight guard.
The PriceClass cast's set() parks the assigned Price object in Laravel's
classCastCache, so the calendar/expansion transforms that rewrite ->price
on fetched rows (relative-rate derivation, cheapest-row browse) serialized
it as {"money":{...}} and the frontend datepicker's Math.round rendered
NaN. A setPriceAttribute mutator now normalizes every write to the
formatted decimal string before it reaches the attribute array.
Several components persist the shared resrv-extras/resrv-options session
keys on every Livewire dehydrate, so a stale child snapshot can clobber
freshly priced add-ons (e.g. extras still priced at the search quantity
after a multi-cart selection changed it). Checkout trusted the stored
prices and validateTotal() threw ReservationDriftException on mount with
no way for the customer to recover.

Checkout now re-prices the session-carried extras and options against the
actual reservation (per child for parents, matching
validateExtraCharges()), the Extras component re-derives enabled prices on
mount and when multi-selections change, and updateEnabledExtraPrices()
leaves rows a filtered component cannot resolve untouched instead of
fataling on the missing extra.
refund_is_automatic keyed off hasGatewayPayment(), but an offline payment
carries a payment_id while its refund() is a bookkeeping no-op, so the
toast claimed the money was returned when the admin still has to move it
by hand. The flag now comes from refundIsAutomatic(), which asks the
resolved gateway whether it supportsAutomaticRefunds().
… store

Checkout blueprints reference sibling fields as form.<handle> because
Livewire\CheckoutForm nests values under form, but the manual-reservation
store validates the same fields under customer.*, so dependent rules like
required_if:form.country,US resolved to nothing and silently passed. Rule
parameters are now rewritten from form. to customer. (regex bodies left
untouched) before validation.
v6.4.0 shipped from main with the reservationFromUri fixes; this
feature now targets the next minor.
Merging main brought Reservation::extraForValidation() (PR #231), whose
contract is that a stale session selection keeps its stored price and is
rejected during validation with an explicit ExtrasException. The branch's
Checkout::repriceEnabledAddons() dropped such rows instead, so a deleted
extra silently un-booked itself and AvailabilityResultsUnitsPerAddonTest::
test_checkout_keeps_the_stored_price_of_a_selection_it_cannot_reprice
failed on the merged tree. Leave unresolvable extras untouched, matching
the options branch of the same method, and price the resolvable ones from
a clone per dataset as ManualReservationCreator already does.

Claude-Session: https://claude.ai/code/session_01WmTzRcQz7eV9so5d4QZHxv
…ferences

- Store the charged line of each manually booked extra in a new nullable
  resrv_reservation_extra.total column; Extra::priceFromPivot() prefers it, so a
  dynamic-pricing clamp no longer renders as price × quantity in the CP.
- Validate manual extras with Extra::selectable(), the same predicate as the quote
  listing and the frontend checkout.
- Treat a CANCELLED booking's own verified intent as retained revenue, not an orphan,
  so a redelivered succeeded webhook after a no-refund cancellation stays quiet.
- Stamp payment_unresolved inside the CANCELLED write for unpaid holds and on every
  out-of-band settlement that keeps an unverified intent reference, so those rows can
  never read as a retained charge; customers cannot self-cancel against such a kept
  reference either.
- Keep the live quote pricing through a rejected custom amount and report the reason,
  so lowering the total re-quotes and shows the error under the field.
Control Panel
- Create page: two-column layout with a sticky summary (itemised price,
  dynamic-pricing discount, override adjustment, amount requested now,
  balance not collected, outcome-aware Create button and blocked reason),
  radio choices with explanations for the amount and payment method, an
  Advanced group (override, decrease availability, affiliate), inline
  "Overbook anyway", field-level errors, required extras flagged, hold
  deadline preview, customer form honouring the blueprint, unsaved-changes
  guard and accessible names.
- Safe submits: an idempotency key makes retried creates return the first
  reservation (a different payload gets a 409 with a link), and expected
  totals refuse creation when the price changed since it was shown.
- Reservation page: header with status badge and gateway-aware actions,
  awaiting-payment summary, Extend hold, payment link field with a working
  copy fallback on http, overdue / arrival-passed / unresolved-payment /
  manual-refund callouts, amounts labelled by state.
- Listing shows awaiting-payment rows by default; modals name the
  reservation; cancel can skip the customer email; calendar shows stock-
  taking holds; Reports gain an outstanding-payments tile.

Payment and state
- Overbooked stays still need a price for every night.
- Out-of-band settlements that keep any intent reference are stamped
  payment_unresolved; refunds of such rows only call the gateway when the
  intent captured, and they are never customer-cancellable.
- The lapse sweep re-checks the deadline under its lock, so a hold extended
  mid-sweep is kept; cancellation side effects always dispatch.
- Awaiting holds no longer block absolute availability edits.
- Standard amounts are capped at the total; zero-amount bookings store no
  gateway or hold and the email switch governs their confirmation.
- Payment closes once the arrival day has passed (pay page and resend).
- CP-created reservations are logged as created in the Control Panel.

Pay page and emails
- Per-state headings, amount and due-by lead block, balance, business
  contact details, loading state and accessibility fixes.
- Offline payment instructions setting rendered on the pay page and in the
  payment request email; reminders for repeat requests; plain hold-lapsed
  wording with the stay details; refund emails promise or hedge the manual
  return according to what the CP knows was received.
- A cancelled reservation whose kept intent is unresolved reports the
  amount received as unknown instead of zero, so the CP no longer labels
  a possibly captured charge "Not collected".
- The outstanding payments tile sums what payment requests ask for,
  including the payment surcharge.
- Past the payment window, the pay page only shows "processing" when the
  gateway reports the stored intent is moving money; a stale or edited
  return URL shows the closed window instead.
- The listing reports a pending checkout's refund by its gateway's
  capability, so the refund modal no longer calls it an offline payment.
- Drop the unused statusLabel() helper and restore the exists() query
  behind hasInFlightReservationsForRange().
…ail rules

- Stamp a kept online intent payment_unresolved inside the locked CONFIRMED
  transition of an out-of-band confirmation, so a customer self-cancellation
  cannot land between the commit and the gateway reconciliation; the verified
  void clears the stamp with the reference.
- Refuse manual creation when the gateway surcharge moved since the quote
  (expected_surcharge, checked once the collecting gateway is final).
- Keep the checkout form's own customer email rules and add required|email
  to them instead of replacing them.
…d-rate stock

- Run each after-response email job isolated: a failed send on the sync
  connection is logged instead of aborting the terminate loop, which skipped
  every later deferred notice (e.g. a lapse sweep cancelling several holds).
  Real queues still rethrow so worker retries apply.
- Show admin-recorded receipts as paid on the customer status page, falling
  back to the online-collected amount when receipt is unknown.
- Bound the manual quote's available count by a shared rate's remaining
  max_available cap so it agrees with the availability status.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants