Repository navigation
Conversation
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.
Contributor
There was a problem hiding this comment.
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_paymentstatus 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)andretrievePaymentIntent(...)) 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'])); | ||
| }); | ||
| } |
…13, clean up boolean-to-int guards
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_PAYMENTstatus until the customer pays online or the hold lapses.Implements plan
009-manual-reservations(all tasks T1–T11).What's included
Core flow
AWAITING_PAYMENTreservation status with manual-reservation columns and a settings toggleManualReservationCreatorservice owning all quote/creation money math (base price, rates, extras incl. custom-amount extras, options, coupons, custom total)Pay-by-link
paymentIntent())resrv:cancel-lapsed-holdscommand: auto-cancels holds past their deadline, restores stock, notifies admin/customer; lazy lapse sweep with a lock re-checkPayment hardening
payment_id; gateway stamped atomically with the intentBC notes
retrievePaymentIntent()signature change is a documented BC break for gateway addons (seeUPGRADE-PAYMENT-GATEWAYS.md); legacy 3-arg gateway shim removedReview 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
Reservation::retrieved()hooks and DB-level updates)DynamicPricingCpTest)