Compare commits

...
30 Commits
Author SHA1 Message Date
arvanitakis 02816fb9e7 Bump Version to 0.17.4 2026-09-15 22:13:31 +03:00
arvanitakis 910ce0205d Feat: Adding command for backfilling all product skus 2026-09-15 22:13:18 +03:00
arvanitakis e532c32cab Bump version to 0.17.3 2026-09-15 21:49:03 +03:00
arvanitakis 6a51b672c8 Fix: Adding a check for hasTable 2026-09-15 21:40:35 +03:00
arvanitakis 956e9e88a6 Fix: Stripping Lunar's Stripe Driver with Boboko's Stripe Payment Driver 2026-09-15 21:38:16 +03:00
arvanitakis 4489475840 Bump version to 0.17.2 2026-09-15 21:25:25 +03:00
arvanitakis e4e008167a Fix: Correct Display of last 4 digits of credit card 2026-09-15 21:22:45 +03:00
arvanitakis a5f3008ce2 Fix: Update Order status to Processing when payment has been recieved 2026-09-15 21:17:56 +03:00
arvanitakis d9fb3bbde6 Bump version to 0.17.1 2026-09-15 16:12:03 +03:00
arvanitakis 26b4c5bfd7 Fix: Move Stripe Payment Intent to always allow redirect 2026-09-15 16:11:31 +03:00
arvanitakis 57fc28ca06 Bump version to 0.17.0 2026-09-14 20:18:26 +03:00
arvanitakis 9d3e54e5df Changelog 2026-09-14 00:04:20 +03:00
arvanitakis 44c6b7defd Feature: Order Updates, Events, Order Flows, Shipment And COD support 2026-09-14 00:03:06 +03:00
arvanitakis 78bbd8390a Feature: Minor Updates to Order Shipping And Order Statuses 2026-09-10 22:50:40 +03:00
arvanitakis 99e55902ac Feat: Updating OrderPlaced Listeners to Decrement Stock, Creating Notifications 2026-09-10 01:34:30 +03:00
arvanitakis 864c8b19aa Feat: Updating Cart Lifecycle Service, and Capping Abandoned Cart Days. Also Updating Cart Views 2026-09-10 01:13:15 +03:00
arvanitakis 8f4c1a22ea Bump version to 0.16.3 2026-09-10 00:14:57 +03:00
arvanitakis 13d5833d18 Fix: Fixing Stripe Payment Driver, Applying Payment Mehtod (COD) fee correctly 2026-09-10 00:14:42 +03:00
arvanitakis 3e45b84636 Bump version to 0.16.2 2026-09-09 23:46:29 +03:00
arvanitakis 437cbf2460 Fix: Updating Shipping Listeners to clear the ShippingManifest options 2026-09-09 23:45:21 +03:00
arvanitakis 5425a0396f Feat: Adding missing nav translations 2026-09-09 23:43:39 +03:00
arvanitakis 4d0e326cb9 Bump version to 0.16.1 2026-09-09 23:25:22 +03:00
arvanitakis d4f9766940 Fix: Correcting spaceing on login form 2026-09-09 23:22:13 +03:00
arvanitakis 359e1e262e Bump Version to 0.16.0 2026-09-09 01:19:31 +03:00
arvanitakis fb684dc97b Feat: Adding Concent Updates 2026-09-09 01:12:21 +03:00
arvanitakis 9c95c0bccb Bump Version to 0.15.0 2026-09-09 00:48:52 +03:00
arvanitakis 73bfc748b4 Feature: Moving Payment Methods to DB, adding fees, Transaction Updates, Refund Updates, General Updates to Payments 2026-09-09 00:48:09 +03:00
arvanitakis 4ff9bdacc3 Bump version to 0.14.0 2026-09-04 13:06:26 +03:00
arvanitakis 55832d9549 Feat: Adding search results for translation 2026-09-04 13:06:07 +03:00
arvanitakis 7b46a83e5e Feat: Product Search Service Restructure 2026-09-04 13:03:38 +03:00
131 changed files with 6344 additions and 751 deletions
+553
View File
@@ -4,6 +4,559 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [0.17.4] - 2026-09-15
### Added
- `boboko:catalog:backfill-skus` — one-off Artisan command to generate a SKU
(`SKU-P{product_id}-V{variant_id}`) for every `Lunar\Models\ProductVariant` left with a `null`
SKU by the earlier Shopify import (the source export's `Variant SKU` column was genuinely blank
for these rows, not an importer mapping bug — see `Modules\MigrateImport\Shopify\
ShopifyExportImporter`). Only touches variants missing a SKU; `--dry-run` lists what would
change without writing.
## [0.17.3] - 2026-09-15
### Changed
- Removed the `lunarphp/stripe` dependency in favour of depending on `stripe/stripe-php` directly.
`Modules\Core\Payment\Drivers\StripePaymentDriver` had already replaced every bit of Lunar's own
Stripe payment flow (checkout, webhook processing) with its own — all that remained load-bearing
from the package was raw API-client access, amount conversion, and a correlation table, none of
which are Lunar-specific. Added first-party replacements: `Modules\Core\Payment\Support\
StripeManager` (API client + `toStripeAmount()`/`fromStripeAmount()`), `Modules\Core\Payment\
Models\StripePaymentIntent` (now with a proper `context` array cast, replacing manual
`json_encode`/`json_decode`), and `Modules\Core\Payment\Http\Middleware\
StripeWebhookMiddleware`. Added `database/migrations/..._create_stripe_payment_intents_table.php`,
a first-party copy of the vendor migration (guarded with `Schema::hasTable()` so it's a no-op on
any environment that already has the table from the vendor package's own earlier migration run,
and only actually creates it on a genuinely fresh install). No behavior change for consuming
apps — same table, same driver contract, same webhook endpoint.
## [0.17.2] - 2026-09-15
### Fixed
- `Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus` never advanced `Order::status` past
`awaiting_payment` on a capture — only `paid`/`paid_at` were written, so a fully captured order
could sit indefinitely at "awaiting payment" until a staff member manually clicked "Update
Status". Now, on `PaymentCaptured` (not `PaymentAuthorized` — an authorization isn't yet
captured funds), `status` advances to the next step in the order's flow
(`Modules\Core\Order\Services\OrderStatusFlow::nextOptions()`) — but only when it's still
exactly `awaiting_payment`, so a duplicate/delayed capture event never regresses an order staff
already moved further.
- The backoffice "Capture" action on the order page (Filament) called vendor Lunar's
`Lunar\Models\Transaction::capture()` directly, which resolves `Lunar\Facades\Payments` — an
entirely separate, unused driver registry — and never dispatched `Modules\Core\Payment\Events\
PaymentCaptured`. This meant a manual capture from the admin panel never ran this app's own
payment pipeline at all (including the status-advance fix above). `Modules\Core\Order\Filament\
Extensions\OrderActionsExtension` (renamed from `OrderRefundActionsExtension`, since it now
fixes both the refund and capture header actions — see below) now routes capture through
`Modules\Core\Payment\Support\TransactionDriverAdapter::capture()`, the same app-level path
checkout-time captures use.
- `Modules\Core\Payment\Drivers\StripePaymentDriver` never extracted a card's brand/last four
digits from Stripe's response, so `Lunar\Models\Transaction::card_type`/`last_four` were always
empty and the admin's "Payment of :amount on card ending :last_four" activity-log line rendered
with no digits — reproduced on both checkout-time and manual captures. Added
`cardMetaFromIntent()`, reading `payment_method_details` off the PaymentIntent's `latest_charge`
(same source `lunarphp/stripe`'s own `StoreCharges` uses), populated into `PaymentResult::$meta`
from `resultFromIntent()` and `capture()`. `Modules\Core\Order\Services\TransactionRecorder`
now maps `meta['card_type']`/`meta['last_four']` onto the `Transaction` row. Only applies to
transactions recorded after this change — existing rows are not backfilled.
### Changed
- `Modules\Core\Order\Filament\Extensions\OrderRefundActionsExtension` renamed to
`OrderActionsExtension` — the class now fixes both the refund and capture header actions on the
order page, not just refund, so the old name undersold its scope.
## [0.17.1] - 2026-09-15
### Fixed
- `Modules\Core\Payment\Drivers\StripePaymentDriver::createAndConfirm()` only set
`automatic_payment_methods` when no `payment_method` was given — the actual checkout flow always
sends one, so it was omitted, and Stripe fell back to whatever payment methods are enabled in the
Dashboard and demanded a `return_url` on confirm. Fixed by setting `automatic_payment_methods`
unconditionally with `allow_redirects: never` — the storefront's Payment Element already restricts
itself to `paymentMethodTypes: ['card']`, so this just tells Stripe the same thing server-side,
which drops the `return_url` requirement.
## [0.17.0] - 2026-09-14
### Added
- `Modules\Core\Order\Notifications\OrderPlacedNotification` — an order confirmation email,
registered against `Modules\Core\Checkout\Events\OrderPlaced` (fires exactly once per order,
regardless of `capture_mode`/driver). Previously only a Stripe (auto-captured) order triggered
any placement email at all, via `OrderCapturedNotification` — a different concern (payment
confirmation) that happened to fire at the same moment for that one driver; an offline or
bank-transfer order got no confirmation whatsoever. Verified live via Mailpit.
- `Modules\Core\Order\Listeners\DecrementStockOnOrderPlaced` — also wired to `OrderPlaced`, the
first stock decrement anywhere in this codebase (previously nothing wrote to
`ProductVariant::stock` as a result of an order at all — overselling was possible). A single
atomic `UPDATE ... SET stock = GREATEST(stock - qty, 0)` per variant, not a read-then-write on
the Eloquent model, to avoid a lost-update race between two orders decrementing the same variant
concurrently. Only touches `purchasable === 'in_stock'` variants on `physical` order lines —
`always`/`backorder` variants are deliberately left alone (their stock has no purchasing
consequence, decrementing it would just make the column an inaccurate negative number). Also
re-triggers Scout reindexing for every affected product, closing the gap `Modules\Core\Catalog\
Services\ProductIndexer`'s own docblock flagged ("nothing currently reindexes a product when an
order decrements its stock") — the search index's `in_stock` filter now reflects the change
immediately rather than only on the next scheduled reindex.
- `Modules\Core\Cart\Services\CartLifecycleService` — the single source of truth for the four
cart lifecycle states (Ongoing, Abandoned Cart, Abandoned Checkout, Completed) documented in
`docs/cart.md`. Previously `Modules\Core\Cart\Filament\Resources\CartResource\Pages\ListCarts`
and `Modules\Core\Cart\Commands\DetectAbandonedCarts` each reimplemented the same query split
independently, which is exactly the kind of drift that lets the admin panel and the
recovery-email pipeline quietly disagree about what "abandoned" means. Both now build on the
same `ongoing()`/`abandonedCarts()`/`abandonedCheckouts()`/`completed()` methods, each taking a
`Builder` so callers compose the scope onto whatever base query they already have — Filament's
own tab query (search/sort/pagination intact) for `ListCarts`, a bare `Cart::query()` for the
command.
- `core.cart.unrecoverable_after` config (default `90 days`) — beyond this age, a stale cart
stops being treated as an active "Abandoned Cart"/"Abandoned Checkout" at all (excluded from
both `CartLifecycleService` methods), rather than staying flagged as an actionable abandonment
forever. A 90-day-old (or older) cart's pricing/stock/tax have very likely moved on, so it's not
a realistic recovery target — this is about the abandoned-cart pipeline only, not data
retention; no rows are deleted or pruned.
- `Modules\Core\Cart\Filament\Resources\CartResource\Pages\ViewCart`'s Lines section now shows
each line's product thumbnail, name (linking to the product's edit page), and variant options —
not just SKU/quantity/price — mirroring Lunar's own order line item display
(`OrderItemsTable`). Also added a new Shipping section: the resolved shipping method name (not
the bare `acs`-style identifier), destination country, shipping total, and each
`shippingBreakdown` line item individually (carrier rate, plus any payment-method fee — see
0.16.3's `ApplyPaymentMethodFee`) so staff can see what makes up the total, not just the sum.
Guards around `Lunar\Models\ProductVariant::getDescription()`/`getOption()`: both are typed to
return `string` but internally read `translateAttribute()`/`translate()`, which return `null`
for a product/option with no attribute data set for the active locale — a real `TypeError` hit
live against an existing test-fixture product. Reads the underlying relations directly instead
of calling through those methods, falling back to "—" rather than crashing the page.
- `Modules\Core\Payment\Drivers\CashOnDeliveryPaymentDriver` — cash-on-delivery/cash-on-pickup was
previously wired to `OfflinePaymentDriver`, the same immediate-capture driver as cash-in-hand,
which meant a COD order was marked paid the instant it was placed even though no money had
actually changed hands. The new driver's `pay()` returns `PaymentResultStatus::Pending` and
dispatches nothing, so payment stays unresolved until staff explicitly confirm cash was received
(see `Order::paid`/`paid_at` below). A data migration repoints the already-seeded
`cash-on-delivery` `PaymentMethod` row to the new driver key.
- `Order::paid`/`paid_at` — an entirely independent boolean/timestamp pair tracking payment,
settable at any point in an order's lifecycle regardless of fulfillment progress. Exists because
cash-on-delivery payment timing has no relationship to the fulfillment sequence at all — a
courier might not reconcile cash for weeks after an order is already marked completed.
### Changed
- **Order status model, redesigned from scratch.** `Order.status` is a single column again
(a same-session 3-axis `payment_status`/`fulfillment_status`/`return_status` design was built,
then abandoned before shipping — three independent selects let staff set any combination with no
cross-field validation, and didn't map onto how staff actually think about an order: one linear
journey, not three simultaneous dials). Now driven by `Modules\Core\Order\Services\
OrderStatusFlow`, a pure transition-table service offering exactly two sequences — carrier and
store-pickup (`Order::isStorePickupOrder()`) — never four; payment method (prepaid vs. COD)
affects `Order::paid` only, not which sequence an order follows or where it sits in it. The
Filament order page's several guided buttons are replaced by three header actions: "Update
Status" offers every status in the order's own branch (`OrderStatusFlow::allOptions()`) — not
just the guided next step — so staff can also revert to an earlier status (e.g. undoing a
mistaken click); it also replaces vendor `ManageOrder`'s own built-in "Update Status" (same
action name, previously left in place unintentionally, producing two duplicate buttons), since
vendor's writes `status` directly with no audit trail or branch validation. It is a PLAIN status
write with no side effects — picking 'dispatched' there does not create a real shipment. "Create
Shipment" is its own separate action, visible only for a carrier order at 'ready_for_dispatch'
(`OrderFulfillmentService::canCreateShipment()`) — the one action that talks to a real carrier
API, so its weight/locker inputs only ever appear for that specific real-world action rather than
inside the general-purpose status select for every manual override of 'dispatched'. "Mark Paid"
is a third, separate header action — `Order::paid` is independent of `status`, so it doesn't
belong bundled into the status select either — visible only when the order's payment method
doesn't auto-capture at checkout (currently only cash-on-delivery). New status vocabulary
(`awaiting_payment`, `processing`, `ready_for_dispatch`/`ready_for_pickup`, `dispatched`,
`delivery_failed`, `picked_up`, `delivered`, `completed`, `return_requested`, `returned`,
`partially_refunded`, `refunded`) replaces the old hyphenated 7-value list in
`config/lunar/orders.php` — a breaking rename backed by a one-time data migration that maps every
existing order onto the new vocabulary (preferring axis-system data where an order was actually
moved through it during this session's testing, falling back to the legacy flat status otherwise)
and derives `paid` from historical transaction data. (The carrier branch's post-delivery status
was initially named `return_window_open`; renamed to `delivered` — same one combined moment,
parcel arrived and return window open — via a follow-up migration once the internal name turned
out to be a confusing thing for staff to see on an order.) A new "Payment Method" entry on the
order summary sidebar (`Order.meta['payment_method']`, falling back to the latest transaction's
driver) surfaces which method a shopper actually used, previously shown nowhere on the order
page. The order list topbar's tabs (Lunar's own `favourite` config flag) are trimmed to the
main-journey statuses only, rather than all twelve — the exception/branch statuses stay reachable
via the table's own filter.
- "Create Shipment"'s form now branches by carrier (`OrderFulfillmentService::carrierFor()`):
- A weight-billed carrier (ACS) gets its weight field pre-filled from the order's own line
weights via the new `Modules\Core\Shipping\Support\WeightCalculator` (the same unit-conversion
table `AcsRateDriver::totalWeightInKg()` already used for live rate quoting, now shared rather
than duplicated) — still staff-editable, not forced.
- Box Now ships by compartment size, not weight, so it gets a repeatable list of boxes (one row
per physical parcel, each with its own S/M/L size — `ShipmentRequest::$boxes`) instead of the
weight field. `BoxNowFulfillmentService::createShipment()` sends one `items` entry per box in a
single delivery request and now creates one `Shipment` row per parcel returned (was hardcoded to
exactly one box/compartmentSize=1, silently ignoring anything beyond the first parcel) — each row
independently trackable/printable/cancellable, linked to its siblings via a shared
`meta['delivery_request_id']`.
- Box Now's locker field is locked read-only once the shopper's own checkout selection
(`$order->shippingAddress->meta['box_now_locker']`) is present — staff can no longer silently
redirect a parcel to a different locker than the one the customer picked at checkout; it's only
editable for the (current, checkout-UI-less) case where nothing set it yet.
- New "Shipments" section on the order page (`Modules\Core\Shipping\Extensions\
OrderShipmentsExtension`, between Transactions and Timeline) — "Create Shipment" previously had no
counterpart anywhere to actually see what it created. One entry per `Shipment` record (a multi-box
Box Now order shows one entry per parcel), rendered as two inline-labelled lines — carrier +
tracking reference, then status + a "Created … · Locker …" helper line — rather than a grid of
individually stacked label/value blocks, which reads as a wall of repeated labels once the admin's
main content area narrows below Filament's own grid breakpoint (1024px, common with the sidebar
open). Two actions per shipment: "Print Label" and "Cancel". Also added `Modules\Core\Shipping\
Http\Controllers\DownloadShipmentLabelController` (short-lived signed URL, same auth model as
Lunar's own vendor order-PDF download) — the only other place that called
`CarrierFulfillmentInterface::printLabel()` (`ManagePickupManifests`' bulk "Print" action)
discarded the returned bytes entirely; this is the first place in the codebase that actually
delivers a label to staff. Hit and fixed two bugs while wiring this up: a `TextEntry` with a blank
`state('')` skips rendering its `suffixActions()` entirely (Filament's own empty-state branch
returns before reaching the actions markup), so the label-download entry needed a real,
non-blank value; and the label-download route, registered via `loadRoutesFrom()` with no
middleware group, had `SubstituteBindings` never run, so a type-hinted `Shipment $shipment`
parameter silently resolved to an empty, non-existent model instead of 404ing — fixed by taking a
plain `int $shipment` and looking the record up directly in the controller.
- `Modules\Core\Shipping\Enums\TrackingStatus::Failed` — previously unused — is now wired to the
new `delivery_failed` status via `Modules\Core\Order\Listeners\
MarkDeliveryFailedOnCarrierCheckpoint`, from which staff can retry dispatch or convert to a
return.
- Fixed a separate, unrelated bug hit while testing the above: `Lunar\Shipping\Models\
ShippingMethod::macro('isStorePickup', ...)` silently never registered — `Lunar\Base\Traits\
HasModelExtending::__callStatic()` (used by every `Lunar\Base\BaseModel` subclass that doesn't
declare its own `macro()`, `ShippingMethod` included) intercepts *every* unmatched static call
and dispatches it as an instance call instead of forwarding to `Macroable`, so `hasMacro()` always
returned `false` and every order was silently treated as carrier-fulfilled — including store-pickup
ones. `Order::isStorePickupOrder()` (the only caller) now reads `ShippingMethod.data
['fulfillment_type']` directly instead of going through the broken macro.
- `CartResource::getEloquentQuery()` no longer filters to carts with a known `user_id`/
`customer_id` — every cart is now listed, guest carts included. Reverses an earlier deliberate
exclusion (an anonymous cart has nothing a staff member could click into — no name, no email),
which held for that specific concern but not for the resource's other real use: seeing how many
carts are ongoing/abandoned right now. Most real storefront traffic never reaches an identified
user/customer, so excluding it silently undercounted exactly what `CartLifecycleService` exists
to report on. A guest row's Customer/User columns just render "—" (no link) rather than the row
being hidden.
- `CartLifecycleService::abandonedCarts()` now requires `whereHas('lines')` — an empty cart
(created but nothing ever added, e.g. a bot, or a session that never shopped) is no longer
counted as "abandoned." There's nothing to recover, so it was a false positive: 9 of 16 carts in
the "Abandoned Cart" tab during testing were empty. Removed the now-redundant post-hoc
`lines->isEmpty()` skip (and its `with('lines')` eager load) from `DetectAbandonedCarts`, since
the query itself excludes them now.
- `Modules\Core\Shipping\Models\Manifest` — a real record of "a manifest was issued", replacing the
loose `shipments.manifest_reference` string. ACS's own `ACS_Issue_Pickup_List` call returns
nothing beyond a `PickupList_No`, so there was previously no way to see which shipments were on a
given manifest, or when it was issued, once the moment passed — only per-shipment breadcrumbs.
`shipments.manifest_id` (FK, replacing `manifest_reference`) now links each shipment to the
`Manifest` row `AcsFulfillmentService::issueManifest()` creates; `ManifestResult::success()`
carries the created `Manifest` instead of a bare reference string. A one-time data migration
backfills a `Manifest` row per distinct existing `(carrier, manifest_reference)` pair, using the
earliest `label_printed_at` (or `updated_at`) among that group as a best-effort `issued_at`, since
the real issue time was never recorded anywhere.
- Split the standalone `Modules\Core\Shipping\Filament\Pages\ManagePickupManifests` page into two
real Filament resources — a bare `Page` has no access to Filament's resource-level pill-tab UI
(`HasTabs` is scoped to `ListRecords`), which carrier-by-carrier separation needed:
- `Modules\Core\Shipping\Filament\Resources\ShipmentResource` ("Pending Vouchers") — shipments not
yet on an issued manifest, one tab per carrier that implements `SupportsManifestBatching` (ACS
today; Box Now has no manifest concept at all — courier pickup is booked at shipment-creation
time — so it gets no tab). Adding a future carrier with its own manifest endpoints (e.g.
Speedex) needs zero UI changes here — tabs are derived from `Shipping::getSupportedDrivers()`,
not hardcoded.
- `Modules\Core\Shipping\Filament\Resources\ManifestResource` ("Issued Manifests") — lists issued
`Manifest` rows (also tabbed by carrier), with a view page and a `ShipmentsRelationManager`
showing which shipments a manifest included, each individually reprintable.
- Both bulk actions ("Print selected", "Issue Manifest") now catch `Throwable` around the actual
carrier API call and surface a Filament notification instead of an unhandled 500 — previously
neither had any error handling at all, so an `AcsApiException` (routine against a voucher/pickup
date the carrier no longer recognizes) crashed the whole page.
- Fixed a bug introduced while building this: `ViewManifest` initially overrode
`getRelationManagers()` directly instead of registering `ShipmentsRelationManager` via
`ManifestResource::getRelations()` (the actual wiring point —
`HasRelationManagers::getAllRelationManagers()` reads from `Resource::getRelations()`, not a
page-level override). The override bypassed the trait's own record-check/caching logic and
broke the relation manager's Livewire component mount, surfacing as a CSRF-token 419 redirect
loop specifically on `/boboko/manifests/{id}`.
- "Create Shipment"'s ACS branch gained a "Number of packages" field (`ShipmentRequest::
$packageCount`, already plumbed through to ACS's `Item_Quantity`/`persistMultipartVouchers()` but
never exposed in the form) — more than 1 issues a main voucher plus a multi-part sub-voucher per
extra package, each its own `Shipment` row sharing the same total weight. The existing weight
field was relabeled "Total weight (kg)" to make explicit that ACS bills by one total shipment
weight, not per package.
## [0.16.3] - 2026-09-10
### Fixed
- Stripe `createAndConfirm()` built its `PaymentIntent` params with
`'automatic_payment_methods' => isset($data['payment_method']) ? null : ['enabled' => true]`. The
Stripe PHP SDK does not omit `null`-valued params from `create()` — it serializes them to an empty
string (`ApiRequestor::_encodeObjects()` → `Util::utf8(null)`), and Stripe's API rejects an empty
`automatic_payment_methods`. Every Stripe charge failed before it started whenever a
`payment_method` was supplied (i.e. every real charge in this flow). Fixed by building `$params`
conditionally so the key is either omitted entirely or set to `['enabled' => true]`, never `null`.
- `Modules\Core\Payment\Filament\Resources\PaymentMethodResource`'s "Driver status" column only
flagged a payment method whose driver *class* no longer resolves (`driver_missing_at`) — it gave
no indication when a driver resolves fine but fails `Configurable::isConfigured()` (e.g. Stripe
enabled in the DB with no `services.stripe.key` set), which `CheckoutService::getPaymentMethods()`
filters out identically. An admin had no way to tell "this method is silently absent at checkout
because of missing config" from "everything's fine" at a glance. The same icon column now also
reflects `isConfigured()`, with a tooltip distinguishing "driver not found" from "missing required
configuration" from "fully configured."
- `Modules\Core\Payment\Pipelines\Cart\ApplyCashOnDeliveryFee` (now `ApplyPaymentMethodFee`) had two
stacked bugs that together meant a configured payment-method fee (e.g. €5 on Cash on Delivery)
never actually reached the cart total:
- `PaymentMethod::where(...)->value('data->fee')` silently returned `null` on Postgres — Laravel's
query builder does not translate the `->` JSON-path column-selector syntax in `value()`/`pluck()`
the way it does inside `where()` clauses, so this resolved to a discarded
`stdClass::$data->fee` property access instead of the actual fee. Fixed by loading the model and
reading the cast `->data['fee']` attribute instead.
- Even with the fee correctly read, adding it directly to `$cart->shippingTotal` didn't survive:
`Lunar\Pipelines\Cart\CalculateTax`, which runs later in the same cart-calculation pipeline,
unconditionally recomputes `shippingTotal` (and shipping tax) from `$cart->shippingBreakdown`'s
item sum — silently discarding anything set only on the plain property. Fixed by adding the fee
as its own `Lunar\Base\ValueObjects\Cart\ShippingBreakdownItem` on `shippingBreakdown` instead,
so it survives `CalculateTax`'s recompute and is correctly included in shipping tax too.
- Also generalized while fixing: the pipeline was hardcoded to the literal type string
`cash-on-delivery`. Renamed to `ApplyPaymentMethodFee` and changed it to look up whichever
`PaymentMethod` row matches `Cart::meta['payment_method']` and apply its own `data.fee` if
present — works for any payment method configured with a fee, not just one specific slug.
- `Modules\Core\Checkout\Services\CheckoutService::selectPaymentMethod()` called `$cart->calculate()`
after saving the new payment method, but `Lunar\Models\Cart::calculate()` no-ops if the cart
instance was already calculated earlier in the same request (`Cart::isCalculated()`) —
`Lunar\Managers\CartSessionManager` memoizes one `Cart` instance per request, so this was true on
every request where the checkout page's initial render had already calculated the cart. The
result: after switching payment methods, the just-saved `meta['payment_method']` change was
persisted, but the cart's totals silently kept reflecting whichever method was calculated *first*
in the request — a shopper switching from Cash in Hand to Cash on Delivery would keep seeing Cash
in Hand's total, with no COD fee applied, until something else forced a fresh calculation. Fixed
by calling `$cart->recalculate()` instead, which forces the pipeline to re-run.
## [0.16.2] - 2026-09-09
### Fixed
- `Lunar\Base\ShippingManifest` is a request-lifetime singleton whose `getOptions()` re-runs the
shipping modifier pipeline without ever clearing its `$options` collection first, and whose
`addOption()` keeps the first entry per `getIdentifier()` and silently drops any later one. In
practice, an option resolved for an earlier shipping address (or cart state) shadowed the
correct one after the address/region changed within the same request — e.g. a carrier priced
differently across two zones that both match an address would keep quoting the stale zone's
price, and `ApplyShipping` would price the cart total off that same stale option. Renamed
`Modules\Core\Shipping\Listeners\FlushLivePricingCache` to
`Modules\Core\Shipping\Listeners\InvalidateShippingOptions` and had it additionally call
`ShippingManifest::clearOptions()`, merged in because both invalidations fire on the exact same
event set (`CartLineAdded`, `CartLineUpdated`, `CartLineRemoved`, `CartCleared`,
`ShippingAddressSet`) — the only inputs the shipping modifier pipeline depends on.
## [0.16.1] - 2026-09-09
### Fixed
- OTP login page (`resources/views/auth/filament/pages/login.blade.php`) had no visible spacing
between the email/OTP input, error text, and buttons following the Filament v3 → v4 upgrade.
The view relied on a bare `grid gap-y-4` Tailwind utility class, but since this view ships from
the `boboko-core` package rather than a consuming app, that class was never present in any
host app's compiled Tailwind output. Replaced with an inline `style` (flex column, `row-gap:
1rem`) so the layout no longer depends on the consuming app's Tailwind content scanning.
## [0.16.0] - 2026-09-08
### Added
- `Modules\Core\Checkout\Services\CheckoutService::setRecoveryConsent(bool $consent): Cart` — the
shopper's promotional/abandoned-cart-recovery opt-in, given once during guest checkout and
deliberately independent of `setShippingAddress()`/`setBillingAddress()`: consent is a
cart-level decision, not tied to any one `CartAddress` — changing which address is on the cart
later never resets or re-asks for it. Only an explicit call to this method (the checkbox itself
being submitted) ever changes it; calling it again with `false` is how a later opt-out is
recorded, per the legal requirement that consent be provable and withdrawable. Stored on
`Cart::meta` (interim, per the design this implements — a real column/consent record is the
eventual target, tracked as follow-up) as `recovery_consent` (bool), `recovery_consent_at`
(ISO 8601, `null` when `false`), and `recovery_consent_policy_version`
(`config('legal.privacy_policy_version')` at the moment of consent, so a later dispute is
answered from what was actually agreed to). Dispatches new
`Modules\Core\Checkout\Events\RecoveryConsentSet`. Newsletter opt-in is explicitly a separate
scope — never merged into this flag.
- `Modules\Core\Checkout\Services\CheckoutService::initiatePayment()` now requires `bool
$termsAccepted` and `string $policyVersion` as mandatory parameters (not optional data a caller
might omit) — throws the new `Modules\Core\Checkout\Exceptions\TermsNotAcceptedException`
*before* `Cart::createOrder()` is ever called if `$termsAccepted` is `false`, so an order can
never exist without a recorded acceptance (refused, not created-then-flagged). On success,
writes `terms_accepted` (`true`), `terms_accepted_at` (ISO 8601), and
`terms_accepted_policy_version` onto the created `Order`'s own `meta` — the durable,
order-level audit trail for a consumer-contract acceptance dispute, written directly (not via
an event/listener) since the `Order` row doesn't exist until `createOrder()` returns.
- `Modules\Core\Cart\Commands\DetectAbandonedCarts` — both its `CartAbandoned` and
`CheckoutAbandoned` detection queries now require `meta->recovery_consent = true`. A
non-consenting cart's abandonment is never dispatched at all (not merely filtered later at
whatever future recovery-email send step reads it) — the correct enforcement point per the
legal requirement that recovery/marketing sends only ever reach carts that opted in.
- `config/legal.php` (merged by a new `Modules\Core\Providers\CheckoutServiceProvider`) —
`privacy_policy_version`/`terms_version`, plain `env()`-backed strings bumped by whoever edits
the corresponding legal page. Recorded alongside every consent/acceptance rather than read live
at dispute time, so what a shopper actually agreed to is answered from the cart/order itself.
`CheckoutServiceProvider` itself is new — `Checkout` previously had no dedicated service
provider at all (its service/events were resolved/dispatched without one).
## [0.15.0] - 2026-09-07
### Changed
- **Breaking:** `Modules\Core\Payment\Models\PaymentMethod` is now the full DB-instance layer for
Payment, same three-layer split (registry / DB instance / cross-cutting config) `Shipping`
already has via `ShippingMethod` — see `docs/payments.md`. Every value that used to live in
`config('lunar.payments.types.{type}.*')` (`payment_driver`, `capture_mode`, `captured_status`)
moves onto the `PaymentMethod` row itself as real columns: `driver` (the new
`PaymentDriverRegistry` key — NOT the same as `type`; two rows can share one driver), `name`
(admin-facing label, nothing played this role before), `capture_mode`, `captured_status`,
`authorized_status`, `position` (admin-controlled ordering, new — reorderable in the Filament
table), `driver_missing_at`. `config('lunar.payments.types')` is gone entirely; `config/
payment.php` now holds only `cart_pipeline` (genuinely cross-cutting — every store gets the
same pipeline wiring regardless of how many payment methods it configures).
- **Breaking:** `Modules\Core\Payment\Services\PaymentDriverResolver` is deleted, replaced by
`Modules\Core\Payment\Services\PaymentDriverRegistry` — `register(string $key, string
$driverClass)`/`resolve(string $key): ?object`/`all(): array<string, string>`. Deliberately
knows nothing about `PaymentMethod` or the database (mirrors `Lunar\Shipping\Managers\
ShippingManager`'s built-in-methods + `Manager::extend()` split, purpose-built rather than
extending `Illuminate\Support\Manager` — Payment's drivers implement several independent
capability interfaces at once, not one uniform contract). Built-ins (`OfflinePaymentDriver`
as `'offline'`, `StripePaymentDriver` as `'stripe'`) registered in
`PaymentServiceProvider::boot()`, exactly how `Shipping::extend('acs', ...)` already works.
- **Breaking:** `Modules\Core\Checkout\Services\CheckoutService::getPaymentMethods()` now returns
`Illuminate\Support\Collection<int, PaymentMethod>` (ordered by `position`), not
`array<string>`. A method is offered only once three independent checks all pass — `enabled`
(admin turned it on), `driver_missing_at` is null (the driver class still exists), and the
resolved driver's own `Configurable::isConfigured()` (its runtime requirements are met) — each
failure meaning something different to an admin diagnosing why a method isn't showing up.
`initiatePayment()` resolves the driver via the selected row's own `driver` column, not `type`.
- `Modules\Core\Payment\Filament\Resources\PaymentMethodResource` — `canCreate()`/`canDelete()`
now both `true` (previously hardcoded `false`, since a row could only ever be a config-defined
type before this release). New create/edit form (`name`, `type`, `driver` — a `Select`
populated live from `PaymentDriverRegistry::all()`, `capture_mode`, `captured_status`,
`authorized_status`); reorderable table (`->reorderable('position')`); a distinct "Driver
status" icon column (separate from the `enabled` toggle) showing whether `driver_missing_at`
is set.
- `Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus` reads `captured_status`/
`authorized_status` off the `PaymentMethod` row (`where('type', $event->type)`) instead of
`config(...)`.
- `Modules\Core\Command\InstallLunarCommand::seedPaymentMethods()` no longer iterates
`config('lunar.payments.types')` — it seeds exactly one opinionated `cash-on-delivery` starter
row, every value a plain literal in the command itself (not sourced from config or the
registry — a driver has no business carrying opinions about what its captured order status
should be called; that's a merchant decision). Skip-if-exists, same as before.
### Added
- `php artisan boboko:payment:sync-drivers` — reconciles every `PaymentMethod` row's `driver`
against `PaymentDriverRegistry`, setting `driver_missing_at` when a driver no longer resolves
(a package removed, a custom `register()` call deleted) and clearing it automatically if that
driver is registered again in a later deploy. Deliberately its own standalone command, meant to
run unconditionally on every container start/deploy (Dockerfile entrypoint, alongside
`migrate`) — "did the set of registered drivers change" is a deploy-time event, cheap enough to
check every time regardless of whether anything actually changed. Verified live: flags a row
whose `driver` was manually corrupted, and auto-clears the flag once the driver resolves again.
- `docs/payments.md` — new "Registry, DB instance, and cross-cutting config" section: the
three-layer split researched against `Shipping`'s own already-existing pattern and three real
e-commerce platforms (Shopify, WooCommerce, Medusa.js), the "would a store ever plausibly want
two different answers to this" test for deciding config vs. DB-column placement, and the
three-check availability chain.
- `Modules\Core\Payment\Services\PaymentMethodCache` (`Cache::rememberForever`, same pattern as
`Localization\Services\LanguageCache`) + `Modules\Core\Payment\Services\PaymentMethodService`
(`create`/`update`/`delete`/`list`) — the single read/write gateway for `PaymentMethod` now used
by every Filament resource action (create, edit, edit-fee, delete, the inline `enabled` toggle)
instead of the Eloquent model directly, so the cache is invalidated and
`PaymentMethodCreated`/`PaymentMethodUpdated`/`PaymentMethodDeleted`/`PaymentMethodsReordered`
dispatch on every write, with no exceptions other than Filament's own drag-to-reorder (which
does a raw bulk SQL `UPDATE` on the position column directly via
`CanReorderRecords`/`reorderTable()`, before `afterReordering()` fires — a confirmed, unavoidable
Filament limitation; the reorder hook only clears the cache and dispatches
`PaymentMethodsReordered` afterward). `CheckoutService::getPaymentMethods()` and
`ApplyResolvedPaymentStatus` both now read through the cache instead of querying `PaymentMethod`
directly.
- `Modules\Core\Payment\Models\CoreTransaction` (a `Lunar\Models\Transaction` subclass) +
`Modules\Core\Payment\Support\TransactionDriverAdapter`, registered via
`Lunar\Facades\ModelManifest::replace(Lunar\Models\Contracts\Transaction::class,
CoreTransaction::class)` — the same contract-swap mechanism already used elsewhere for
`Customer`/`Staff`. Fixes a real crash (`InvalidArgumentException: Driver [cash-on-delivery] not
supported`) the first time anything called `$transaction->refund()`/`->capture()`:
`Lunar\Models\Transaction::driver()` calls Lunar's own, entirely separate
`Lunar\Facades\Payments::driver()` manager, which had never heard of any of this codebase's
driver keys. `CoreTransaction::driver()` returns `TransactionDriverAdapter` instead, which
resolves the transaction's real `PaymentMethod`/`PaymentDriverRegistry` driver and calls it —
Lunar's own admin panel "Refund"/"Capture" buttons now transparently reach the real payment
system underneath, including correctly reporting failure (not a silently-faked success) when
the resolved driver doesn't implement `SupportsRefunds`/`SupportsCaptures`.
- `TransactionDriverAdapter::refundVia(Transaction $transaction, ?string $driverKey, int $amount,
?string $notes = null)` — refund through an explicitly chosen driver, independent of the one
the original payment went through (e.g. a cash-on-delivery order refunded via Bank Transfer,
which has no notion of the original offline payment at all). The order page's refund action
gained a "Refund via" `Select` (every `PaymentDriverRegistry` driver implementing
`SupportsRefunds`, defaulting to the transaction's own driver) that routes through this method
instead of `Lunar\Models\Transaction::refund()`, whose fixed signature has no room for a driver
override.
- `Modules\Core\Payment\Drivers\BankTransferPaymentDriver` (registered as `'bank-transfer'`) —
manual/attested, same trust model as `OfflinePaymentDriver`: no gateway call, `pay()`/`refund()`
decide success immediately on a staff member's say-so. Implements both `SupportsPay` and
`SupportsRefunds`; exists specifically so a payment taken through a different method can still
be refunded via bank transfer. The admin UI for receiving a payment this way (bank reference,
notes, proof-of-transfer upload) is a follow-up — the driver itself is complete and usable via
the registry today.
- `Modules\Core\Order\Filament\Infolists\TransactionEntry` (swapped in for Lunar's own
`Lunar\Admin\Support\Infolists\Components\Transaction` via a new
`OrderTransactionsExtension::extendTransactionsRepeatableEntry()` hook) — the order page's
transaction cards now also show a note recorded in `Transaction.meta['notes']` when the `notes`
column itself is empty. `Order\Services\TransactionRecorder` only ever wrote `notes` from
`PaymentResult::$failureReason`, which is never set on a successful result — a manual driver's
staff-entered note (e.g. `BankTransferPaymentDriver`'s) was being recorded but had nowhere to
render.
- `Modules\Core\Payment\Listeners\LogPaymentMethodActivity` — `PaymentMethod` now has an admin
activity trail, unlike `Order`/`Transaction`/`Staff` it previously had none. Routes
`PaymentMethodCreated`/`Updated`/`Deleted` through the existing `Logging\ActivityLogService`
(the same one `Localization\Listeners\LogTranslationActivity` already uses) rather than adding
`PaymentMethod` to `Lunar\Base\Traits\LogsActivity`'s generic model-observer logging —
`PaymentMethodUpdated::$old`/`PaymentMethodDeleted::$method`'s snapshot already carry more
deliberate before/after context than Eloquent's own dirty-attribute diffing would reconstruct.
`PaymentMethodsReordered` is deliberately NOT logged — a multi-row position change doesn't fit
`ActivityLogService`'s one-`Model`-subject shape, and isn't worth a new method for a low-stakes,
purely-cosmetic setting.
- New `payment_methods.refunded_status` column + form field (same `Select` pattern as
`captured_status`/`authorized_status`) — `ApplyResolvedPaymentStatus` now also reacts to
`PaymentRefunded`, so `Order.status` actually changes on a refund; before this, only the
*derived* `Order::paymentStatus()` reflected a refund (reading `transactions` live), while the
stored `status` column — what admin filtering, customer emails, etc. actually key off — never
moved. Resolves the ORIGINAL payment method for this lookup, not the refund event's own
`$type`: a refund routed through a different driver via `refundVia()` (e.g. cash-on-delivery
refunded through Bank Transfer) carries the REFUND driver's registry key as `$event->type`,
which usually isn't even a real `PaymentMethod.type` — the listener now finds the order's
earliest successful `capture`/`intent` transaction instead and reads `refunded_status` off
*that* transaction's own `PaymentMethod` row, since that's the payment the refund is actually
reversing. Deliberately no `void_status` yet — a void never moved money, so it doesn't carry
the same "the customer needs to see this changed" weight a refund does.
### Fixed
- Existing `PaymentMethod` rows seeded before this release (`cash-on-delivery`, `cash-in-hand`)
had `driver`/`capture_mode`/`captured_status` all `NULL` after the migration ran — a data
backfill was required (not automated by the migration itself) to restore them to a resolvable
state; flagged here since a consuming app upgrading past this release needs the same backfill
for its own pre-existing rows before `getPaymentMethods()` will offer them again.
- `Lunar\Admin\Filament\Resources\OrderResource\Pages\ManageOrder::getRefundAction()`/
`getCaptureAction()` and `OrderItemsTable::getBulkRefundAction()` report a failed refund/capture
by calling `$action->failureNotification(...)`, `$action->failure()`, then `$action->halt()` —
but `Filament\Actions\Concerns\InteractsWithActions::callMountedAction()` only ever sends that
notification from a code path that runs after the action's closure returns normally; `halt()`
throws `Filament\Support\Exceptions\Halt`, caught by an earlier `catch` block that rolls back and
returns, so the notification was built but never sent — clicking "Refund" on a payment method
that genuinely can't be refunded looked like nothing happened at all, no error, no toast. Real,
pre-existing Filament/Lunar bug, invisible until this release's `TransactionDriverAdapter` made
an honest failure (rather than a hard crash or a silently-faked success) actually reachable.
Fixed via new `Modules\Core\Order\Filament\Extensions\OrderRefundActionsExtension`/
`OrderItemsTableExtension`, which wrap the affected actions' closures to send the queued failure
notification themselves before re-throwing `Halt`.
- `TransactionDriverAdapter::refund()`/`capture()` never included `order_id` in the `$context`
passed to the driver, so `Order\Listeners\RecordPaymentTransaction`/`ApplyResolvedPaymentStatus`
(both requiring `$context['order_id']`) silently no-op'd for every admin-initiated refund/capture
through any driver — no audit `Transaction` row was ever created, regardless of whether the
refund/capture itself succeeded. Fixed by passing `$transaction->order_id` through.
## [0.14.0] - 2026-09-03
### Changed
- **Breaking:** `Modules\Core\Catalog\Services\ProductSearchService::search()` now returns `Modules\Core\Catalog\DTOs\ProductListingResult` — the exact same shape `ProductService::list()` already returns — instead of a bare `Illuminate\Database\Eloquent\Collection<Product>` of hydrated models with no pagination at all. New signature: `search(string $query, ?ProductFilters $filters = null, ?ProductSort $sort = null, int $perPage = 24, int $page = 1): ProductListingResult`. `->products` is a real `LengthAwarePaginator` of plain, localized indexed-document arrays (not Eloquent models, not Scout's raw response) — a search results page and a category listing page are now interchangeable from a controller's perspective: same DTO, same `ProductCard::fromIndexed()` mapping, same pagination/sort/tag/price-slider handling. `->priceBounds`/`->availableTags` are scoped to the search query itself (delegated to `ProductService::priceSliderBounds()`/`availableTags()`, both of which already accepted a `$query` param for this).
- `Modules\Core\Catalog\Services\ProductService::availableTags()` is now `public` (was `private`) and takes an optional `$query` parameter, so `ProductSearchService::search()` can reuse it directly instead of reimplementing the same facet call.
### Added
- `Modules\Core\Catalog\Support\ProductDocumentLocalizer` — the per-locale field resolution and raw-Meilisearch-response unwrapping (`withLocalizedFields()`, `hitsFrom()`) extracted out of `ProductService` into its own class, since `ProductSearchService` needed the exact same logic against the exact same kind of document. Both services now depend on this one class instead of `ProductService` owning logic a second service also needed.
## [0.13.0] - 2026-09-03
### Changed
+3 -2
View File
@@ -2,7 +2,7 @@
"name": "boboko/core",
"description": "Core module — authentication and shared panel behaviour",
"type": "library",
"version": "0.13.1",
"version": "0.17.4",
"autoload": {
"psr-4": {
"Modules\\Core\\": "src/"
@@ -18,7 +18,7 @@
"lunarphp/search": "*",
"lunarphp/meilisearch": "*",
"spatie/laravel-translation-loader": "^2.8",
"lunarphp/stripe": "^1.5"
"stripe/stripe-php": "^16.6"
},
"require-dev": {
"fakerphp/faker": "^1.23",
@@ -37,6 +37,7 @@
"Modules\\Core\\Providers\\CoreServiceProvider",
"Modules\\Core\\Providers\\AuthServiceProvider",
"Modules\\Core\\Providers\\CustomerServiceProvider",
"Modules\\Core\\Providers\\CheckoutServiceProvider",
"Modules\\Core\\Providers\\PaymentServiceProvider",
"Modules\\Core\\Providers\\LocalizationServiceProvider",
"Modules\\Core\\Providers\\CatalogServiceProvider",
+33
View File
@@ -30,6 +30,39 @@ return [
'cart' => [
'abandoned_after' => '1 hour',
/*
|----------------------------------------------------------------------
| Unrecoverable Cap
|----------------------------------------------------------------------
|
| Beyond this age, a stale cart stops being treated as an active
| "Abandoned Cart"/"Abandoned Checkout" (Modules\Core\Cart\Services\
| CartLifecycleService) — too old to be a realistic recovery target
| (pricing/stock/tax likely stale by then). This is about the
| abandoned-cart pipeline only, not data retention — no rows are
| deleted or pruned based on this value.
|
*/
'unrecoverable_after' => '90 days',
],
/*
|--------------------------------------------------------------------------
| Order Return Window
|--------------------------------------------------------------------------
|
| How many days after a carrier order is delivered (Order::fulfillment_status
| becomes 'return_window_open') before Modules\Core\Order\Commands\
| CloseExpiredReturnWindows auto-completes it, if no return was requested.
| Store-pickup orders have no return-window step and are unaffected by
| this value (see Modules\Core\Order\Listeners\CompleteOrderOnPickedUp).
|
*/
'order' => [
'return_window_days' => 14,
],
];
+21
View File
@@ -0,0 +1,21 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Policy versions
|--------------------------------------------------------------------------
|
| Plain version strings, bumped by whoever edits the corresponding legal
| page — recorded alongside every consent/acceptance so a later dispute
| ("what did the shopper actually agree to?") can be answered from the
| order/cart itself rather than a live lookup against whatever the pages
| say TODAY. Not tied to any CMS/database row on purpose — this stays a
| plain config value the same way payment.php's cart_pipeline is a plain
| cross-cutting setting, not a per-instance one.
|
*/
'privacy_policy_version' => env('LEGAL_PRIVACY_POLICY_VERSION', '2026-01-01'),
'terms_version' => env('LEGAL_TERMS_VERSION', '2026-01-01'),
];
+13 -33
View File
@@ -1,48 +1,28 @@
<?php
use Modules\Core\Payment\Drivers\OfflinePaymentDriver;
use Modules\Core\Payment\Pipelines\Cart\ApplyCashOnDeliveryFee;
use Modules\Core\Payment\Pipelines\Cart\ApplyPaymentMethodFee;
return [
/*
|--------------------------------------------------------------------------
| Lunar payment types merged in by Boboko Core
|--------------------------------------------------------------------------
|
| These are merged into config('lunar.payments.types') so every app using
| boboko-core gets cash-on-delivery out of the box, without publishing
| Lunar's own config.
|
| 'payment_driver' is boboko-owned, alongside Lunar's own 'driver' key —
| the driver instance Modules\Core\Payment\Services\PaymentDriverResolver
| resolves via the container. 'capture_mode' ('pay' or 'authorize') is
| also boboko-owned — which contract method
| CheckoutService::initiatePayment() calls for this type. Kept on the
| same row as 'driver' rather than a second, separately-keyed map, so a
| type's full definition lives in one place.
|
*/
'types' => [
'cash-on-delivery' => [
'driver' => 'offline',
'payment_driver' => OfflinePaymentDriver::class,
'capture_mode' => 'pay',
'captured_status' => 'payment-offline',
'fee' => 0,
],
],
/*
|--------------------------------------------------------------------------
| Lunar cart pipeline additions
|--------------------------------------------------------------------------
|
| Appended to config('lunar.cart.pipelines.cart') after ApplyShipping so
| the cash-on-delivery fee is added to the shipping total before the
| final Calculate step sums everything up.
| the selected payment method's own fee (if any) is added to the
| shipping total before the final Calculate step sums everything up.
|
| This is the one thing left in this file — everything about WHICH
| payment methods exist (driver mapping, capture_mode, statuses) moved
| onto Modules\Core\Payment\Models\PaymentMethod's own row (see
| docs/payments.md): that's a per-instance, merchant decision, not a
| store-wide-singular setting, so it never belonged in config at all.
| This pipeline registration IS genuinely cross-cutting — every store
| using this driver gets the same cart-pipeline wiring, regardless of
| how many payment methods it configures.
|
*/
'cart_pipeline' => [
ApplyCashOnDeliveryFee::class,
ApplyPaymentMethodFee::class,
],
];
@@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Lunar\Base\Migration;
/**
* First-party copy of lunarphp/stripe's own create_stripe_payment_intents_table
* migration (package removed in favour of depending on stripe/stripe-php
* directly — see Modules\Core\Payment\Support\StripeManager and
* Modules\Core\Payment\Models\StripePaymentIntent, which replace the
* package's own classes over this same table). Timestamped to run just
* before this app's own add_context_to_stripe_payment_intents migration,
* which already alters this table.
*
* Guarded with hasTable(): on any environment that already ran
* lunarphp/stripe's own copy of this migration before the package was
* removed, the table already exists — this migration is only the one that
* actually creates it on a fresh install/database from now on.
*/
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable($this->prefix.'stripe_payment_intents')) {
return;
}
Schema::create($this->prefix.'stripe_payment_intents', function (Blueprint $table) {
$table->id();
$table->foreignId('cart_id')->constrained($this->prefix.'carts');
$table->foreignId('order_id')->nullable()->constrained($this->prefix.'orders');
$table->string('intent_id')->index();
$table->string('status')->nullable();
$table->string('event_id')->index()->nullable();
$table->timestamp('processing_at')->nullable();
$table->timestamp('processed_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists($this->prefix.'stripe_payment_intents');
}
};
@@ -0,0 +1,52 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Moves the driver mapping and per-type behavior that used to live in
* config('lunar.payments.types.{type}.*') onto the PaymentMethod row
* itself — same DB-instance-vs-config split Modules\Core\Shipping's own
* shipping_methods table already has (code/driver/name/enabled columns,
* no driver mapping in any config file). See docs/payments.md.
*
* - driver: the Modules\Core\Payment\Services\PaymentDriverRegistry key
* (NOT the same as `type` — two rows can share one driver).
* - name: admin-facing label. Nothing played this role before; `type`
* was always the machine slug.
* - capture_mode / captured_status / authorized_status: per-instance
* behavior — fails the "would a store ever want two different answers
* to this" cross-cutting-config test, so these move off config.
* - position: admin-controlled display/checkout order.
* - driver_missing_at: set by the payment:sync-drivers command when
* `driver` no longer resolves via the registry — deliberately
* separate from `enabled`, so a driver vanishing (a deploy removed
* it) is never confused with an admin's own manual toggle, and a
* driver that comes back later auto-clears this with no admin action.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('payment_methods', function (Blueprint $table) {
$table->string('name')->nullable()->after('type');
$table->string('driver')->nullable()->after('name');
$table->string('capture_mode')->nullable()->after('driver');
$table->string('captured_status')->nullable()->after('capture_mode');
$table->string('authorized_status')->nullable()->after('captured_status');
$table->unsignedInteger('position')->default(0)->after('authorized_status');
$table->timestamp('driver_missing_at')->nullable()->after('position');
});
}
public function down(): void
{
Schema::table('payment_methods', function (Blueprint $table) {
$table->dropColumn([
'name', 'driver', 'capture_mode', 'captured_status',
'authorized_status', 'position', 'driver_missing_at',
]);
});
}
};
@@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* captured_status/authorized_status (added in 2026_09_05_000001) cover a
* payment being taken, but nothing wrote Order.status on a REFUND —
* Order::paymentStatus() (Order\Support\OrderStatus::payment(), derived
* live from transactions) already reflects a refund correctly, but the
* stored status column — the one admin filtering, customer emails, etc.
* actually key off — never moved. Same reasoning as captured_status/
* authorized_status: a store could plausibly want a different resulting
* status per payment method (e.g. a "Refunded" vs. a "Refund Pending"
* variant), so this is a PaymentMethod column, not cross-cutting config.
*
* Deliberately no separate void_status — void never moved money (it
* releases an authorization hold before any capture), so it doesn't carry
* the same "the customer needs to see this changed" weight a refund does;
* add one later if a real need for it shows up.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('payment_methods', function (Blueprint $table) {
$table->string('refunded_status')->nullable()->after('authorized_status');
});
}
public function down(): void
{
Schema::table('payment_methods', function (Blueprint $table) {
$table->dropColumn('refunded_status');
});
}
};
@@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Splits Lunar's single flat `status` column into three independently
* tracked axes — payment, fulfillment, return — so a payment refund and a
* fulfillment dispatch stop racing to write the same field, and each axis
* can be filtered/queried directly instead of overloading one string for
* three unrelated concerns. See Modules\Core\Order\Enums\OrderPaymentStatus/
* OrderFulfillmentStatus/OrderReturnStatus for the value vocabularies, and
* Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus and friends for
* where these columns actually get written. `status` itself is left in
* place, unchanged — Lunar core still reads/writes it in places this
* package doesn't own — but nothing in this package's business logic keys
* off it anymore after this migration's consumers land.
*
* lunar_customers already has a direct precedent for a boboko-core
* migration altering a Lunar-owned table (see
* 2026_07_02_000002_drop_otp_from_lunar_customers_table.php) — this is not
* a new pattern for this codebase, just the first time it's applied to
* lunar_orders.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('lunar_orders', function (Blueprint $table) {
$table->string('payment_status')->default('awaiting_payment')->after('status')->index();
$table->string('fulfillment_status')->default('unfulfilled')->after('payment_status')->index();
$table->string('return_status')->default('none')->after('fulfillment_status')->index();
});
}
public function down(): void
{
Schema::table('lunar_orders', function (Blueprint $table) {
$table->dropColumn(['payment_status', 'fulfillment_status', 'return_status']);
});
}
};
@@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Append-only audit trail for Order's three status axes (see
* 2026_09_11_000001_add_status_axes_to_orders_table.php) — the thing
* `Lunar\Models\Order::getDefaultLogExcept()` explicitly denies (`status`
* is excluded from Lunar's own Spatie activity log), so this is a
* from-scratch mechanism, not a gap in an existing one.
*
* No `updated_at` — a row is never edited after it's written, only ever
* inserted. `event_class` is the FQCN of whatever business event/action
* caused the write (e.g. Modules\Core\Order\Events\OrderDispatched, or a
* plain string like 'Modules\Core\Shipping\Extensions\OrderViewExtension::
* markDispatchedAction' for a manual Filament action that has no backing
* event class of its own) — see Modules\Core\Order\Services\
* OrderStatusTransitionRecorder.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('order_status_transitions', function (Blueprint $table) {
$table->id();
$table->foreignId('order_id')->constrained('lunar_orders')->cascadeOnDelete();
$table->string('axis');
$table->string('from_status')->nullable();
$table->string('to_status');
$table->string('event_class');
$table->timestamp('created_at')->useCurrent();
$table->index(['order_id', 'axis']);
});
}
public function down(): void
{
Schema::dropIfExists('order_status_transitions');
}
};
@@ -0,0 +1,79 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Lunar\Models\Order;
use Modules\Core\Order\Enums\PaymentStatus;
use Modules\Core\Order\Support\OrderStatus;
/**
* Maps every existing order's flat `status` (as it stood before
* 2026_09_11_000001_add_status_axes_to_orders_table.php) onto the new
* payment_status/fulfillment_status/return_status columns. A separate
* migration from the schema change so the schema migration stays simply
* reversible via down(), and this data pass can be independently re-run.
*
* The flat status never captured refunds at all (no 'refunded' value was
* ever added to config('lunar.orders.statuses')), so the table-driven
* mapping below is corrected per-order by re-deriving
* Modules\Core\Order\Support\OrderStatus::payment() — the existing,
* unchanged derived-enum logic — and overriding payment_status to
* refunded/partially_refunded wherever it disagrees with the flat-status
* mapping. This is the one place the "keep the old derived enums" design
* decision earns its keep: refund-fraction math isn't reimplemented here,
* just reused.
*/
return new class extends Migration
{
private const MAP = [
'awaiting-payment' => ['payment_status' => 'awaiting_payment', 'fulfillment_status' => 'unfulfilled'],
'payment-offline' => ['payment_status' => 'awaiting_payment', 'fulfillment_status' => 'unfulfilled'],
'payment-received' => ['payment_status' => 'paid', 'fulfillment_status' => 'unfulfilled'],
'ready-for-dispatch' => ['payment_status' => 'paid', 'fulfillment_status' => 'ready'],
'ready-for-pickup' => ['payment_status' => 'paid', 'fulfillment_status' => 'ready'],
'dispatched' => ['payment_status' => 'paid', 'fulfillment_status' => 'in_transit'],
'completed' => ['payment_status' => 'paid', 'fulfillment_status' => 'completed'],
];
public function up(): void
{
Order::query()->with('transactions')->chunkById(200, function ($orders) {
foreach ($orders as $order) {
$mapped = self::MAP[$order->status] ?? null;
if ($mapped === null) {
Log::warning('Order status axis backfill: unmapped status, leaving column defaults', [
'order_id' => $order->id,
'status' => $order->status,
]);
continue;
}
$paymentStatus = $mapped['payment_status'];
$derived = OrderStatus::payment($order);
if ($derived === PaymentStatus::Refunded) {
$paymentStatus = 'refunded';
} elseif ($derived === PaymentStatus::PartialRefund) {
$paymentStatus = 'partially_refunded';
}
DB::table('lunar_orders')->where('id', $order->id)->update([
'payment_status' => $paymentStatus,
'fulfillment_status' => $mapped['fulfillment_status'],
'return_status' => 'none',
]);
}
});
}
public function down(): void
{
// Column defaults (set in the schema migration) are the correct
// "undo" — no need to reverse-map back to the flat status, since
// `status` itself was never touched by this migration.
}
};
@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* captured_status/authorized_status/refunded_status let a merchant pick
* which per-method Order::status label a payment outcome resulted in — a
* mechanism that only made sense while Order.status was the single field
* carrying that meaning. Modules\Core\Order\Listeners\
* ApplyResolvedPaymentStatus now writes a fixed 3-value payment_status
* column instead (see 2026_09_11_000001_add_status_axes_to_orders_table.php);
* there is no longer any per-method flexibility to preserve — "paid" is
* "paid" regardless of which method captured it. Dropped rather than left
* vestigial: keeping them visible in the admin would let a merchant
* configure something that silently does nothing.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('payment_methods', function (Blueprint $table) {
$table->dropColumn(['captured_status', 'authorized_status', 'refunded_status']);
});
}
public function down(): void
{
Schema::table('payment_methods', function (Blueprint $table) {
$table->string('captured_status')->nullable();
$table->string('authorized_status')->nullable();
$table->string('refunded_status')->nullable();
});
}
};
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Order::paid/paid_at — entirely independent of the `status` column (see
* Modules\Core\Order\Services\OrderStatusFlow's own docblock for why
* payment timing, especially for cash-on-delivery, cannot be modeled as a
* status-sequence step). `paid` is the fast-filter boolean; `paid_at` is
* when it actually happened.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('lunar_orders', function (Blueprint $table) {
$table->boolean('paid')->default(false)->after('status')->index();
$table->timestamp('paid_at')->nullable()->after('paid');
});
}
public function down(): void
{
Schema::table('lunar_orders', function (Blueprint $table) {
$table->dropColumn(['paid', 'paid_at']);
});
}
};
@@ -0,0 +1,116 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Lunar\Models\Order;
use Modules\Core\Order\Enums\PaymentStatus;
use Modules\Core\Order\Support\OrderStatus;
/**
* Collapses the 3-axis (payment_status/fulfillment_status/return_status)
* model this session briefly built — abandoned before shipping — back
* onto a single `status` column plus the new independent `paid`/`paid_at`
* fields. Must run after 2026_09_12_000001 (adds paid/paid_at) and before
* 2026_09_12_000003 (drops the axis columns this migration still reads).
*
* Priority rule: axis data where it's genuinely non-default (this order
* was really moved through the axis system during this session's manual
* testing); the legacy `status` column (which may still hold pre-session
* hyphenated values) as fallback everywhere else.
*/
return new class extends Migration
{
private const LEGACY_MAP = [
'awaiting-payment' => 'awaiting_payment',
'payment-offline' => 'awaiting_payment',
'payment-received' => 'processing',
'ready-for-dispatch' => 'ready_for_dispatch',
'ready-for-pickup' => 'ready_for_pickup',
'dispatched' => 'dispatched',
'completed' => 'completed',
];
/**
* Axis fulfillment_status -> new single status, given branch. Axis
* 'delivered' folds into 'return_window_open' (same combined-value
* decision the going-forward design makes). Axis payment_status is
* used only to decide whether a fully-unfulfilled order should read
* as 'awaiting_payment' or 'processing'.
*/
private function mapFromAxes(string $payment, string $fulfillment, string $return, bool $isPickup): ?string
{
if ($return === 'returned') {
return 'returned';
}
if ($return === 'requested') {
return 'return_requested';
}
return match ($fulfillment) {
'unfulfilled' => $payment === 'paid' ? 'processing' : 'awaiting_payment',
'processing' => 'processing',
'ready' => $isPickup ? 'ready_for_pickup' : 'ready_for_dispatch',
'in_transit' => 'dispatched',
'delivered', 'return_window_open' => 'return_window_open',
'picked_up' => 'picked_up',
'completed' => 'completed',
default => null,
};
}
public function up(): void
{
Order::query()->with('transactions')->chunkById(200, function ($orders) {
foreach ($orders as $order) {
$isPickup = $order->isStorePickupOrder();
$axisIsDefault = $order->payment_status === 'awaiting_payment'
&& $order->fulfillment_status === 'unfulfilled'
&& $order->return_status === 'none';
$status = $axisIsDefault
? (self::LEGACY_MAP[$order->status] ?? null)
: $this->mapFromAxes($order->payment_status, $order->fulfillment_status, $order->return_status, $isPickup);
if ($status === null) {
Log::warning('Single-status backfill: unmapped order, defaulting to awaiting_payment', [
'order_id' => $order->id,
'status' => $order->status,
'payment_status' => $order->payment_status,
'fulfillment_status' => $order->fulfillment_status,
'return_status' => $order->return_status,
]);
$status = 'awaiting_payment';
}
$derived = OrderStatus::payment($order);
$paid = $order->payment_status === 'paid'
|| in_array($derived, [PaymentStatus::Captured, PaymentStatus::Refunded, PaymentStatus::PartialRefund], true);
// A refund implies the order concluded via a return —
// even one backfilled to an early status (e.g. an order
// refunded before fulfillment ever started) is corrected
// to refunded/partially_refunded here, not left stuck
// pre-fulfillment with no sign a refund ever happened.
if ($derived === PaymentStatus::Refunded) {
$status = 'refunded';
} elseif ($derived === PaymentStatus::PartialRefund) {
$status = 'partially_refunded';
}
DB::table('lunar_orders')->where('id', $order->id)->update([
'status' => $status,
'paid' => $paid,
'paid_at' => $paid ? ($order->placed_at ?? now()) : null,
]);
}
});
}
public function down(): void
{
// No reverse mapping — column defaults (post-rollback of the
// schema migrations) are the correct "undo".
}
};
@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Reverses 2026_09_11_000001_add_status_axes_to_orders_table.php — the
* 3-axis model was abandoned before shipping in favor of a single
* `status` column plus independent `paid`/`paid_at` (see
* 2026_09_12_000001/000002). Must run after 2026_09_12_000002, which
* still reads these columns for the backfill.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('lunar_orders', function (Blueprint $table) {
$table->dropColumn(['payment_status', 'fulfillment_status', 'return_status']);
});
}
public function down(): void
{
// Mirrors 2026_09_11_000001's own down() — restores columns
// empty/defaulted, does not attempt to resurrect real per-order
// values.
Schema::table('lunar_orders', function (Blueprint $table) {
$table->string('payment_status')->default('awaiting_payment')->after('paid_at')->index();
$table->string('fulfillment_status')->default('unfulfilled')->after('payment_status')->index();
$table->string('return_status')->default('none')->after('fulfillment_status')->index();
});
}
};
@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* There is only one status column left to audit (plus the synthetic
* 'paid' entry — see Modules\Core\Order\Listeners\RecordStatusTransition),
* so the `axis` column this table was created with
* (2026_09_11_000002_create_order_status_transitions_table.php) no longer
* means anything.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('order_status_transitions', function (Blueprint $table) {
$table->dropIndex(['order_id', 'axis']);
$table->dropColumn('axis');
$table->index('order_id');
});
}
public function down(): void
{
Schema::table('order_status_transitions', function (Blueprint $table) {
$table->dropIndex(['order_id']);
$table->string('axis')->default('status')->after('order_id');
$table->index(['order_id', 'axis']);
});
}
};
@@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* The seeded 'cash-on-delivery' PaymentMethod row
* (Modules\Core\Command\InstallLunarCommand::seedPaymentMethods()) was
* wired to driver => 'offline' — the same immediate-capture driver as
* cash-in-hand. That's the bug that made COD "pay immediately" instead of
* waiting for staff to confirm cash was actually received. Repoints
* already-seeded environments to the new dedicated
* Modules\Core\Payment\Drivers\CashOnDeliveryPaymentDriver; the seeder
* itself is fixed separately for fresh installs.
*/
return new class extends Migration
{
public function up(): void
{
DB::table('payment_methods')->where('type', 'cash-on-delivery')->update(['driver' => 'cash-on-delivery']);
}
public function down(): void
{
DB::table('payment_methods')->where('type', 'cash-on-delivery')->update(['driver' => 'offline']);
}
};
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* 'return_window_open' is renamed to 'delivered' — same status value,
* same meaning (the parcel arrived AND the return window is now open,
* still one combined moment — see Modules\Core\Order\Listeners\
* AdvanceFulfillmentOnDelivered), just a name a merchant expects to read
* on the order page rather than an internal mechanic. Also renames it in
* order_status_transitions' audit rows so the history stays consistent
* with `status` going forward.
*/
return new class extends Migration
{
public function up(): void
{
DB::table('lunar_orders')->where('status', 'return_window_open')->update(['status' => 'delivered']);
DB::table('order_status_transitions')->where('from_status', 'return_window_open')->update(['from_status' => 'delivered']);
DB::table('order_status_transitions')->where('to_status', 'return_window_open')->update(['to_status' => 'delivered']);
}
public function down(): void
{
DB::table('lunar_orders')->where('status', 'delivered')->update(['status' => 'return_window_open']);
DB::table('order_status_transitions')->where('from_status', 'delivered')->update(['from_status' => 'return_window_open']);
DB::table('order_status_transitions')->where('to_status', 'delivered')->update(['to_status' => 'return_window_open']);
}
};
@@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* A real record of "a manifest was issued", not just a loose
* manifest_reference string stamped onto each Shipment row — ACS's own
* ACS_Issue_Pickup_List call returns nothing beyond a PickupList_No (see
* Modules\Core\Shipping\Carriers\Acs\AcsFulfillmentService::issueManifest()),
* so this table is entirely our own bookkeeping: when the manifest was
* issued and how many shipments it included, not something re-derivable
* from the carrier later. `shipment_count` is denormalized (also
* countable via shipments()->count()) purely so the manifests list can
* render without an extra query per row.
*
* carrier-agnostic by design — see Modules\Core\Shipping\Contracts\
* SupportsManifestBatching, the same contract any future carrier
* (Speedex, etc.) implements to get manifest batching at all; this table
* has no ACS-specific columns.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('manifests', function (Blueprint $table) {
$table->id();
$table->string('carrier');
$table->string('reference');
$table->unsignedInteger('shipment_count')->default(0);
$table->timestamp('issued_at');
$table->timestamps();
$table->unique(['carrier', 'reference']);
});
}
public function down(): void
{
Schema::dropIfExists('manifests');
}
};
@@ -0,0 +1,82 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Replaces the loose manifest_reference string with a real manifests
* relation — see 2026_09_13_000002_create_manifests_table.php. Backfills
* one Manifest row per distinct (carrier, manifest_reference) pair
* already present in shipments, using the earliest label_printed_at (or
* updated_at as a fallback) among that group as a best-effort issued_at,
* since the exact original issue time was never recorded anywhere.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('shipments', function (Blueprint $table) {
$table->foreignId('manifest_id')->nullable()->after('manifest_reference')->constrained()->nullOnDelete();
});
$groups = DB::table('shipments')
->select('carrier', 'manifest_reference')
->whereNotNull('manifest_reference')
->distinct()
->get();
foreach ($groups as $group) {
$shipments = DB::table('shipments')
->where('carrier', $group->carrier)
->where('manifest_reference', $group->manifest_reference)
->get();
$issuedAt = $shipments->pluck('label_printed_at')->filter()->min()
?? $shipments->pluck('updated_at')->min();
$manifestId = DB::table('manifests')->insertGetId([
'carrier' => $group->carrier,
'reference' => $group->manifest_reference,
'shipment_count' => $shipments->count(),
'issued_at' => $issuedAt,
'created_at' => $issuedAt,
'updated_at' => $issuedAt,
]);
DB::table('shipments')
->where('carrier', $group->carrier)
->where('manifest_reference', $group->manifest_reference)
->update(['manifest_id' => $manifestId]);
}
Schema::table('shipments', function (Blueprint $table) {
$table->dropColumn('manifest_reference');
});
}
public function down(): void
{
Schema::table('shipments', function (Blueprint $table) {
$table->string('manifest_reference')->nullable()->after('parent_reference');
});
DB::table('shipments')
->whereNotNull('manifest_id')
->orderBy('id')
->each(function ($shipment) {
$manifest = DB::table('manifests')->find($shipment->manifest_id);
if ($manifest) {
DB::table('shipments')->where('id', $shipment->id)->update([
'manifest_reference' => $manifest->reference,
]);
}
});
Schema::table('shipments', function (Blueprint $table) {
$table->dropConstrainedForeignId('manifest_id');
});
}
};
+48 -40
View File
@@ -1,28 +1,34 @@
# Cart Admin Visibility
`Modules\Core\Cart\Filament\Resources\CartResource` gives staff read-only visibility into
customer/user carts in the Filament admin panel. Lunar itself ships no cart admin view at
all — no Filament resource for `Cart`/`CartLine` exists anywhere in `lunarphp/lunar` or
`lunarphp/core` — this is a from-scratch addition, not an extension of something Lunar
half-built. See `docs/lunar.md`'s "Cart and Checkout" section for the underlying Lunar cart
mechanics this resource reads from.
every cart in the Filament admin panel, guest carts included. Lunar itself ships no cart
admin view at all — no Filament resource for `Cart`/`CartLine` exists anywhere in
`lunarphp/lunar` or `lunarphp/core` — this is a from-scratch addition, not an extension of
something Lunar half-built. See `docs/lunar.md`'s "Cart and Checkout" section for the
underlying Lunar cart mechanics this resource reads from.
---
## Scope: only carts with a known customer or user
## Scope: every cart, identified or not
`CartResource::getEloquentQuery()` filters to `Cart::whereNotNull('user_id')->orWhereNotNull('customer_id')`
— an anonymous guest's session cart is excluded entirely.
`CartResource` lists every cart the four lifecycle states (below) cover, with no
`user_id`/`customer_id` filter — an anonymous guest's session cart is included.
This was a deliberate call, not an oversight: an anonymous cart carries no identity a staff
member could act on — no name, no email, nothing to follow up with — so listing every guest
session cart would be noise, not a real admin capability. This does **not** mirror Shopify's
admin (Shopify has no "all carts" view at all — only "Abandoned checkouts," gated on a
shopper reaching checkout and entering contact info, a later/narrower stage than Lunar's
`Cart`). Lunar's own `Cart` model already gets `user_id`/`customer_id` set the moment a
shopper is authenticated (via `Lunar\Listeners\CartSessionAuthListener` on login), with no
checkout step required — so scoping to "identifiable" here is broader than Shopify's
equivalent, not a copy of it.
This was a reversal of an earlier, deliberate call to exclude guest carts entirely (on the
reasoning that an anonymous cart carries no identity a staff member could act on — no name, no
email, nothing to follow up with — so listing every guest session cart would be noise, not a
real admin capability). That reasoning holds for "can I click through to a Customer record,"
but not for the resource's other real use — seeing how many carts are ongoing/abandoned right
now regardless of who's shopping. Most real storefront traffic never reaches an identified
user/customer, so excluding it silently undercounts exactly the thing `ListCarts`'s tabs (and
`CartLifecycleService`, which they and `DetectAbandonedCarts` both build on) exist to report
on. The `Customer`/`User` columns on a guest row just render "—" (Filament's `placeholder()`)
instead of a link — nothing to click into, but the row and its contents are still visible via
`ViewCart`.
This does **not** mirror Shopify's admin (Shopify has no "all carts" view at all — only
"Abandoned checkouts," gated on a shopper reaching checkout and entering contact info, a
later/narrower stage than Lunar's `Cart`).
---
@@ -40,28 +46,29 @@ distinct states together: no order ever started, vs. a draft order exists
different purchase-intent signals (see "Abandoned Cart vs Abandoned Checkout" below) and
different reachability (checkout usually captures an email even for a guest), so
`ListCarts::getTabs()` splits them into four tabs instead of `scopeActive()`'s two-state
split:
split.
- **Ongoing** — `scopeActive()` and recent `updated_at` (within `abandonedCutoff()`). Default
active tab on page load.
- **Abandoned Cart** — `whereDoesntHave('orders')` and stale `updated_at`.
- **Abandoned Checkout** — has an order with `placed_at IS NULL`, and stale `updated_at`.
- **Completed** — has an order with `placed_at IS NOT NULL`.
`Modules\Core\Cart\Services\CartLifecycleService` is the single source of truth for these four
query shapes — both `ListCarts::getTabs()` (staff browsing) and `DetectAbandonedCarts`
(abandonment-event dispatch) build on it, rather than each reimplementing the same split
independently (which is what happened before this service existed, and is exactly the kind of
drift that lets the admin panel and the recovery-email pipeline quietly disagree about what
"abandoned" means):
```php
// Ongoing
$query->active()->where('updated_at', '>', CartResource::abandonedCutoff());
- **Ongoing** (`ongoing()`) — `scopeActive()` and recent `updated_at` (within
`abandonedCutoff()`). Default active tab on page load.
- **Abandoned Cart** (`abandonedCarts()`) — `whereDoesntHave('orders')` and stale
`updated_at`.
- **Abandoned Checkout** (`abandonedCheckouts()`) — has an order with `placed_at IS NULL`,
and stale `updated_at`.
- **Completed** (`completed()`) — has an order with `placed_at IS NOT NULL`.
// Abandoned Cart
$query->whereDoesntHave('orders')->where('updated_at', '<=', CartResource::abandonedCutoff());
// Abandoned Checkout
$query->whereHas('orders', fn ($q) => $q->whereNull('placed_at'))
->where('updated_at', '<=', CartResource::abandonedCutoff());
// Completed
$query->whereHas('orders', fn ($q) => $q->whereNotNull('placed_at'));
```
Each method takes a `Builder` and returns it further scoped, so callers compose it onto
whatever base query they already have (`CartResource::getEloquentQuery()` for the Filament
tabs, a bare `Cart::query()` for the command). Deliberately query-shape-only: consent
(`meta->recovery_consent`) and non-empty-lines filtering stay in `DetectAbandonedCarts`, not on
the service — those gate whether a recovery *event* should fire, not what "abandoned" means to
a staff member browsing the list.
There is deliberately **no "All" tab.** Every row shown is always scoped to one of the four
states above — the list never runs an unfiltered `Cart::query()->get()` over the whole
@@ -111,7 +118,7 @@ runs once per admin page load, not once per cart row.
```php
public static function getNavigationBadge(): ?string
{
return (string) static::getEloquentQuery()->active()->count();
return (string) static::getEloquentQuery()->active()->where('updated_at', '<=', static::abandonedCutoff())->count();
}
```
@@ -235,9 +242,10 @@ just upper-cases the code; `Lunar\Managers\DiscountManager::validateCoupon()` (v
via a normal Eloquent write, so there's no model-event hook to dispatch from directly.
`Modules\Core\Cart\Commands\DetectAbandonedCarts` (registered on an hourly schedule by
`Modules\Core\Providers\CartServiceProvider`) is the only place that moment gets detected: it
queries the same two branches `ListCarts::getTabs()` uses (no order at all vs. draft order
never placed) and dispatches `Modules\Core\Recovery\Events\CartAbandoned`/`CheckoutAbandoned`
for anything currently stale.
builds on the same `CartLifecycleService::abandonedCarts()`/`abandonedCheckouts()` queries
`ListCarts::getTabs()` uses (no order at all vs. draft order never placed) and dispatches
`Modules\Core\Recovery\Events\CartAbandoned`/`CheckoutAbandoned` for anything currently stale
that also has `meta->recovery_consent = true`.
### Cart/Checkout have zero abandonment-related writes — by design
@@ -2,7 +2,7 @@
@if (! $otpSent)
<form wire:submit="requestOtp">
<div class="grid gap-y-4">
<div style="display: flex; flex-direction: column; row-gap: 1rem;">
<x-filament::input.wrapper>
<x-filament::input
type="email"
@@ -24,7 +24,7 @@
</form>
@else
<form wire:submit="authenticate">
<div class="grid gap-y-4">
<div style="display: flex; flex-direction: column; row-gap: 1rem;">
<p class="text-sm text-gray-500">
A login code was sent to <strong>{{ $email }}</strong>.
</p>
@@ -0,0 +1,127 @@
@php
$transaction = $getRecord();
$notes = $transaction->notes ?: ($transaction->meta['notes'] ?? null);
@endphp
@once
@php
$renderPaymentIcons();
@endphp
@endonce
<div
@class([
'text-sm rounded-lg shadow-md border dark:bg-gray-900',
'text-gray-950 dark:text-white',
match($transaction->type){
'refund' => 'border-orange-300',
'intent' => 'border-sky-300',
'capture' => 'border-green-300',
default => 'border-gray-300',
},
'!border-red-500 bg-red-50' => !$transaction->success,
'bg-gray-50' => $transaction->success,
])
>
<div class="p-2 space-y-2">
<div class="px-4 py-2 rounded text-xs bg-white dark:bg-gray-800 shadow text-gray-600 dark:text-gray-400 ring-1 ring-gray-100 dark:ring-gray-700">
<span>{{ $transaction->driver }}</span> //
<span>{{ $transaction->reference }}</span>
</div>
<div class="flex items-center justify-between p-4 bg-white dark:bg-gray-800 rounded shadow ring-1 ring-gray-100 dark:ring-gray-700">
<div class="flex items-center gap-6">
<div>
<strong class="text-xs">
{{ $transaction->status }}
</strong>
</div>
<div>
<svg viewBox="0 0 50 50" class="w-10">
<use xlink:href="#{{ strtolower($transaction->card_type) }}"></use>
</svg>
</div>
@if($transaction->last_four)
<p class="text-sm">
<span class="inline-block -translate-y-px">
&lowast;&lowast;&lowast;&lowast; &lowast;&lowast;&lowast;&lowast; &lowast;&lowast;&lowast;&lowast;
</span>
<span class="font-medium">
{{ (string) $transaction->last_four }}
</span>
</p>
@endif
</div>
<strong
@class([
"text-sm",
'text-red-500' => !$transaction->success,
match($transaction->type){
'refund' => "text-orange-500",
default => "text-gray-900 dark:text-gray-100",
},
])
>
@if($transaction->type == 'refund')-@endif{{ $transaction->amount->formatted }}
</strong>
</div>
<div class="px-4 py-2 bg-white dark:bg-gray-800 shadow rounded flex items-center justify-between text-gray-600 dark:text-gray-400 ring-1 ring-gray-100 dark:ring-gray-700">
<div class="text-xs flex items-center gap-2">
<div>
<x-filament::icon
icon="heroicon-o-clock"
class="w-4"
/>
</div>
<span>{{ $transaction->created_at->format('jS F Y h:ia') }}</span>
</div>
<div class="flex space-x-2">
@foreach($transaction->paymentChecks() as $check)
<x-filament::badge
:icon="$check->successful ? 'heroicon-m-check' : 'heroicon-m-x-mark'"
:color="$check->successful ? \Filament\Support\Colors\Color::Sky : 'gray'"
>
{{ $check->label }}: {{ $check->message }}
</x-filament::badge>
@endforeach
</div>
</div>
@if($notes)
<div class="px-4 py-2 bg-white dark:bg-gray-800 shadow flex items-center rounded gap-2 ring-1 ring-gray-100 dark:ring-gray-700">
<div>
<x-filament::icon
icon="heroicon-o-chat-bubble-oval-left-ellipsis"
class="w-4"
/>
</div>
<p class="text-sm">{{ $notes }}</p>
</div>
@endif
</div>
<div
@class([
"bottom-0 left-0 block w-full text-center rounded-b-lg border-t text-xs py-1",
"!bg-red-50 !dark:bg-red-400/10 !border-red-300 !text-red-600 !dark:text-red-400" => !$transaction->success,
match($transaction->type){
'refund' => "bg-orange-50 dark:bg-orange-400/10 border-orange-300 text-orange-600 dark:text-orange-400",
'intent' => "bg-sky-50 dark:bg-sky-400/10 border-sky-300 text-sky-600 dark:text-sky-400",
'capture' => "bg-green-50 dark:bg-green-400/10 border-green-300 text-green-600 dark:text-green-400",
default => "bg-gray-50 dark:bg-gray-400/10 border-gray-300 text-gray-600 dark:text-gray-400",
},
])
>
@if(!$transaction->success)
{{ __('lunarpanel::order.transactions.failed') }}
@else
{{ __('lunarpanel::order.transactions.'.$transaction->type) }}
@endif
</div>
</div>
@@ -0,0 +1,3 @@
<p>Hi,</p>
<p>Your order <strong>{{ $reference }}</strong> is complete. Thanks for shopping with us!</p>
@@ -0,0 +1,3 @@
<p>Hi,</p>
<p>Your order <strong>{{ $reference }}</strong> is on its way.</p>
@@ -0,0 +1,3 @@
<p>Hi,</p>
<p>Your order <strong>{{ $reference }}</strong> is ready for pickup in store.</p>
@@ -0,0 +1,11 @@
<p>Hi,</p>
<p>Thanks for your order! Your order <strong>{{ $reference }}</strong> is confirmed.</p>
<ul>
@foreach ($lines as $line)
<li>{{ $line->quantity }} &times; {{ $line->description }} — {{ $line->total?->formatted }}</li>
@endforeach
</ul>
<p>Total: <strong>{{ $total }}</strong></p>
@@ -1,3 +0,0 @@
<x-filament-panels::page>
{{ $this->table }}
</x-filament-panels::page>
+20 -15
View File
@@ -5,7 +5,7 @@ namespace Modules\Core\Cart\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Event;
use Lunar\Models\Cart;
use Modules\Core\Cart\Filament\Resources\CartResource;
use Modules\Core\Cart\Services\CartLifecycleService;
use Modules\Core\Recovery\Events\CartAbandoned;
use Modules\Core\Recovery\Events\CheckoutAbandoned;
@@ -31,6 +31,20 @@ use Modules\Core\Recovery\Events\CheckoutAbandoned;
* state at all; every cart still matching the query below refires its event
* on every run until Recovery (not yet built — see
* docs/recovery-strategies.md) owns its own dedup/tracking table.
*
* Both queries require meta->recovery_consent = true — CartAbandoned/
* CheckoutAbandoned exist specifically to drive future recovery-email
* sends (Checkout\Services\CheckoutService::setRecoveryConsent() is where
* that consent is actually recorded), and a non-consenting cart's
* abandonment must never be dispatched at all, not merely filtered later
* at send time — see docs referenced above for the legal reasoning. This
* consent filter stays here rather than on Modules\Core\Cart\Services\
* CartLifecycleService, whose two "abandoned" queries this command builds
* on — dispatch eligibility is this command's own concern, not part of
* what "abandoned" means to a staff member browsing the admin panel. (The
* non-empty-lines requirement, by contrast, IS part of what "abandoned"
* means either way, so it lives on CartLifecycleService::abandonedCarts()
* itself, not here.)
*/
class DetectAbandonedCarts extends Command
{
@@ -38,32 +52,23 @@ class DetectAbandonedCarts extends Command
protected $description = 'Dispatch CartAbandoned/CheckoutAbandoned for carts that just crossed the abandonment threshold.';
public function handle(): void
public function handle(CartLifecycleService $lifecycle): void
{
$cutoff = CartResource::abandonedCutoff();
$cartsAbandoned = 0;
$checkoutsAbandoned = 0;
Cart::query()
->whereDoesntHave('orders')
->where('updated_at', '<=', $cutoff)
->with('lines')
$lifecycle->abandonedCarts(Cart::query())
->where('meta->recovery_consent', true)
->chunkById(200, function ($carts) use (&$cartsAbandoned) {
foreach ($carts as $cart) {
if ($cart->lines->isEmpty()) {
continue;
}
Event::dispatch(new CartAbandoned($cart));
$cartsAbandoned++;
}
});
Cart::query()
->whereHas('orders', fn ($query) => $query->whereNull('placed_at'))
->where('updated_at', '<=', $cutoff)
$lifecycle->abandonedCheckouts(Cart::query())
->where('meta->recovery_consent', true)
->with(['orders' => fn ($query) => $query->whereNull('placed_at')])
->chunkById(200, function ($carts) use (&$checkoutsAbandoned) {
foreach ($carts as $cart) {
+15 -14
View File
@@ -9,20 +9,22 @@ use Modules\Core\Cart\Filament\Resources\CartResource\Pages\ViewCart;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
use Lunar\Admin\Filament\Resources\CustomerResource;
use Lunar\Models\Cart;
use Modules\Core\Cart\Filament\Resources\CartResource\Pages;
use Modules\Core\Cart\Services\CartLifecycleService;
/**
* Read-only — a cart is managed entirely through the storefront (add/update/remove
* line, checkout), never hand-edited by staff. Scoped to carts with a known
* `user_id`/`customer_id` only: an anonymous guest's session cart carries no
* identity a staff member could act on (no name, no email, nothing to follow up
* with), so listing every such row would be noise, not a real admin capability —
* see docs/cart.md for the reasoning (Lunar itself ships no cart admin view at all
* to follow a precedent from).
* line, checkout), never hand-edited by staff. Lists every cart, guest carts
* included — see docs/cart.md ("Scope: every cart, identified or not"). An
* anonymous cart's Customer/User columns just render "—" (see table() below)
* rather than the row being hidden outright: most real traffic never reaches
* an identified user/customer, and "how many carts are ongoing/abandoned
* right now" is a real reporting need regardless of identity — excluding
* anonymous carts would silently undercount it. Lunar itself ships no cart
* admin view at all to follow a precedent from.
*/
class CartResource extends Resource
{
@@ -36,12 +38,6 @@ class CartResource extends Resource
protected static ?string $pluralModelLabel = 'Carts';
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()
->where(fn (Builder $query) => $query->whereNotNull('user_id')->orWhereNotNull('customer_id'));
}
/**
* Count only, not a fetch — no rows are loaded. Combines BOTH abandoned
* states (`active()` already covers "no order at all" and "draft order,
@@ -56,6 +52,11 @@ class CartResource extends Resource
return (string) static::getEloquentQuery()->active()->where('updated_at', '<=', static::abandonedCutoff())->count();
}
public static function lifecycle(): CartLifecycleService
{
return app(CartLifecycleService::class);
}
/**
* `Cart::scopeActive()` (not-yet-converted-to-an-order carts) mixes two very
* different things together: a cart someone is actively shopping in right now,
@@ -67,7 +68,7 @@ class CartResource extends Resource
*/
public static function abandonedCutoff(): Carbon
{
return now()->sub(config('core.cart.abandoned_after', '1 hour'));
return static::lifecycle()->abandonedCutoff();
}
public static function table(Table $table): Table
@@ -6,6 +6,7 @@ use Filament\Schemas\Components\Tabs\Tab;
use Filament\Resources\Pages\ListRecords;
use Illuminate\Database\Eloquent\Builder;
use Modules\Core\Cart\Filament\Resources\CartResource;
use Modules\Core\Cart\Services\CartLifecycleService;
class ListCarts extends ListRecords
{
@@ -27,30 +28,24 @@ class ListCarts extends ListRecords
* bucket — same distinction Modules\Core\Recovery\Events\CartAbandoned /
* Modules\Core\Recovery\Events\CheckoutAbandoned draw.
*
* "Ongoing" vs the two abandoned tabs all split on `updated_at` against
* `CartResource::abandonedCutoff()` — Lunar has no time-based staleness
* signal of its own, so recent activity is the only thing distinguishing a
* cart someone is shopping in right now from one genuinely left behind.
* The four query shapes below live on Modules\Core\Cart\Services\
* CartLifecycleService, shared with Modules\Core\Cart\Commands\
* DetectAbandonedCarts — see that service's docblock for why duplicating
* them independently in both places was worth centralizing.
*/
public function getTabs(): array
{
$lifecycle = app(CartLifecycleService::class);
return [
'abandoned_cart' => Tab::make('Abandoned Cart')
->modifyQueryUsing(fn(Builder $query) => $query
->whereDoesntHave('orders')
->where('updated_at', '<=', CartResource::abandonedCutoff())),
->modifyQueryUsing(fn (Builder $query) => $lifecycle->abandonedCarts($query)),
'abandoned_checkout' => Tab::make('Abandoned Checkout')
->modifyQueryUsing(fn(Builder $query) => $query
->whereHas('orders', fn(Builder $query) => $query->whereNull('placed_at'))
->where('updated_at', '<=', CartResource::abandonedCutoff())),
->modifyQueryUsing(fn (Builder $query) => $lifecycle->abandonedCheckouts($query)),
'ongoing' => Tab::make('Ongoing')
->modifyQueryUsing(fn(Builder $query) => $query->active()->where('updated_at', '>', CartResource::abandonedCutoff())),
->modifyQueryUsing(fn (Builder $query) => $lifecycle->ongoing($query)),
'completed' => Tab::make('Completed')
->modifyQueryUsing(fn(Builder $query) => $query->whereHas(
'orders',
fn(Builder $query) => $query->whereNotNull('placed_at'),
)),
->modifyQueryUsing(fn (Builder $query) => $lifecycle->completed($query)),
];
}
}
@@ -5,12 +5,18 @@ namespace Modules\Core\Cart\Filament\Resources\CartResource\Pages;
use Filament\Schemas\Schema;
use Filament\Schemas\Components\Section;
use Filament\Actions\Action;
use Filament\Infolists\Components\ImageEntry;
use Filament\Infolists\Components\RepeatableEntry;
use Filament\Infolists\Components\TextEntry;
use Filament\Resources\Pages\ViewRecord;
use Filament\Support\Colors\Color;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Facades\Blade;
use Lunar\Admin\Filament\Resources\CustomerResource;
use Lunar\Admin\Filament\Resources\ProductResource\Pages\EditProduct;
use Lunar\Models\Cart;
use Lunar\Models\CartLine;
use Lunar\Models\ProductVariant;
use Modules\Core\Cart\Filament\Resources\CartResource;
class ViewCart extends ViewRecord
@@ -35,12 +41,23 @@ class ViewCart extends ViewRecord
* (a single view page load), not per-row in the list table, since running the
* full pipeline for every row of a paginated table would be expensive for no
* real benefit — see docs/lunar.md's Cart gotchas.
*
* Eager-loads what the Lines section (below) reads off each line's
* purchasable — name, thumbnail, options — the same relations Lunar's
* own OrderItemsTable loads for an order's line items (`with(['purchasable'])`,
* see vendor/lunarphp/lunar/.../OrderItemsTable::getDefaultTable()) — so
* rendering the product grid doesn't N+1 per line.
*/
protected function resolveRecord(int|string $key): Cart
{
/** @var Cart $cart */
$cart = parent::resolveRecord($key);
$cart->load('lines.purchasable', 'shippingAddress.country');
EloquentCollection::make($cart->lines->pluck('purchasable')->filter(fn ($p) => $p instanceof ProductVariant))
->loadMissing(['product.thumbnail', 'images', 'values']);
return $cart->calculate();
}
@@ -77,6 +94,37 @@ class ViewCart extends ViewRecord
RepeatableEntry::make('lines')
->hiddenLabel()
->schema([
ImageEntry::make('image')
->hiddenLabel()
->state(fn (CartLine $record) => $record->purchasable instanceof ProductVariant
? $record->purchasable->getThumbnail()?->getUrl('small')
: null)
->defaultImageUrl(fn () => 'data:image/svg+xml;base64,'.base64_encode(
Blade::render('<x-filament::icon icon="heroicon-o-photo" style="color:rgb('.Color::Gray[400].');"/>')
))
->imageSize(48),
TextEntry::make('description')
->label('Product')
// ProductVariant::getDescription()/getOption() are typed
// string but internally read translateAttribute()/
// translate(), which return null for a product/option
// with no attribute data set for the active locale —
// reading the underlying relations directly here avoids
// that TypeError rather than calling through them.
->state(fn (CartLine $record) => $record->purchasable instanceof ProductVariant
? ($record->purchasable->product?->translateAttribute('name') ?? '—')
: '—')
->url(fn (CartLine $record) => $record->purchasable instanceof ProductVariant
? EditProduct::getUrl(['record' => $record->purchasable->product_id])
: null)
->weight('bold'),
TextEntry::make('options')
->label('Options')
->state(fn (CartLine $record) => $record->purchasable instanceof ProductVariant
? ($record->purchasable->values->map(fn ($value) => $value->translate('name'))->filter()->join(', ') ?: null)
: null)
->placeholder('—')
->badge(),
TextEntry::make('purchasable.sku')
->label('SKU')
->placeholder('—'),
@@ -90,6 +138,53 @@ class ViewCart extends ViewRecord
])
->columns(4),
]),
Section::make('Shipping')
->columns(3)
->schema([
TextEntry::make('shippingAddress.shipping_option')
->label('Shipping method')
// The raw identifier (e.g. "acs") is all a
// CartAddress row stores — the human-readable
// name only exists on the resolved
// Lunar\DataTypes\ShippingOption, which is what
// shippingBreakdown's items are keyed/named
// from below, so fall back to that name rather
// than showing the bare identifier.
->formatStateUsing(fn (Cart $record, ?string $state) => $state
? ($record->shippingBreakdown?->items->get($state)?->name ?? $state)
: null)
->placeholder('Not selected'),
TextEntry::make('shippingAddress.country.name')
->label('Shipping to')
->placeholder('—'),
TextEntry::make('shippingTotal')
->label('Shipping total')
->formatStateUsing(fn (Cart $record) => $record->shippingTotal?->formatted() ?? '—')
->weight('bold'),
RepeatableEntry::make('shippingBreakdownItems')
->label('Breakdown')
->columnSpanFull()
// shippingBreakdown->items is a plain (non-Eloquent)
// Collection of Lunar\Base\ValueObjects\Cart\
// ShippingBreakdownItem — e.g. the carrier rate and,
// separately, Modules\Core\Payment\Pipelines\Cart\
// ApplyPaymentMethodFee's own line item when the
// selected payment method carries a fee (see
// CHANGELOG 0.16.3) — both show up here individually
// rather than only as the summed shippingTotal above.
->state(fn (Cart $record) => $record->shippingBreakdown?->items->values() ?? [])
->schema([
TextEntry::make('name')
->hiddenLabel(),
TextEntry::make('price')
->hiddenLabel()
->formatStateUsing(fn ($state) => $state?->formatted() ?? '—')
->alignEnd(),
])
->columns(2)
->visible(fn (Cart $record) => (bool) $record->shippingBreakdown?->items->isNotEmpty()),
])
->visible(fn (Cart $record) => $record->shippingAddress !== null),
Section::make('Totals')
->columns(3)
->schema([
@@ -0,0 +1,99 @@
<?php
namespace Modules\Core\Cart\Services;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
use Lunar\Models\Cart;
/**
* The single source of truth for the four cart lifecycle states documented in
* docs/cart.md ("Four states, not two — and not Cart::completed_at"). Both
* Modules\Core\Cart\Filament\Resources\CartResource/ListCarts (staff-facing
* browsing/tabs) and Modules\Core\Cart\Commands\DetectAbandonedCarts
* (abandonment-event dispatch) build on these same four query shapes — before
* this existed, each reimplemented them independently, which is exactly the
* kind of drift that lets the admin panel and the recovery-email pipeline
* quietly disagree about what "abandoned" means.
*
* `Cart::completed_at` is declared/cast on the model but never actually
* written anywhere in Lunar core — not a real signal, not used here.
* `Cart::scopeActive()` (Lunar's own "not yet converted to an order" scope)
* mixes two distinct states together (no order at all vs. a draft order that
* was never placed) — see docs/cart.md for why they're kept apart as
* different purchase-intent/reachability signals rather than folded into one
* "not converted" bucket.
*
* Query shape only: consent (`meta->recovery_consent`) and non-empty-lines
* filtering stay in DetectAbandonedCarts, not here — those are specific to
* whether a recovery event should fire, not to what "abandoned" means. Staff
* browsing the admin panel should see every abandoned cart, consenting or
* not.
*
* `unrecoverableCutoff()` is a second, older threshold
* (`core.cart.unrecoverable_after`, default 90 days) applied as a lower
* bound on both abandoned*() methods below: a cart past it is too old to be
* a realistic recovery target (pricing/stock/tax have likely moved on), so
* it drops out of "Abandoned Cart"/"Abandoned Checkout" entirely rather than
* staying flagged as an actionable abandonment forever. It does not appear
* in `ongoing()`/`completed()` either — this is about the abandoned-cart
* pipeline specifically, not a retention/deletion policy (no rows are
* touched here).
*/
class CartLifecycleService
{
public function abandonedCutoff(): Carbon
{
return now()->sub(config('core.cart.abandoned_after', '1 hour'));
}
public function unrecoverableCutoff(): Carbon
{
return now()->sub(config('core.cart.unrecoverable_after', '90 days'));
}
/**
* Not yet converted to an order (scopeActive()), with recent activity —
* someone plausibly shopping right now, not (yet) left behind.
*/
public function ongoing(Builder $query): Builder
{
return $query->active()->where('updated_at', '>', $this->abandonedCutoff());
}
/**
* No order started at all, stale, not yet past the unrecoverable cap, and
* actually has something in it — the weaker of the two abandoned states
* (see docs/cart.md's "Abandoned Cart vs Abandoned Checkout"). An empty
* cart (created but nothing ever added — e.g. a bot, or a session that
* never shopped) was never really "abandoned"; there's nothing to
* recover, so it's excluded rather than counted as a false positive.
*/
public function abandonedCarts(Builder $query): Builder
{
return $query->whereDoesntHave('orders')
->whereHas('lines')
->where('updated_at', '<=', $this->abandonedCutoff())
->where('updated_at', '>', $this->unrecoverableCutoff());
}
/**
* A draft order exists (checkout was started) but was never placed,
* stale, and not yet past the unrecoverable cap — the stronger of the
* two abandoned states.
*/
public function abandonedCheckouts(Builder $query): Builder
{
return $query->whereHas('orders', fn (Builder $query) => $query->whereNull('placed_at'))
->where('updated_at', '<=', $this->abandonedCutoff())
->where('updated_at', '>', $this->unrecoverableCutoff());
}
/**
* Has an order that was actually placed, not just drafted.
*/
public function completed(Builder $query): Builder
{
return $query->whereHas('orders', fn (Builder $query) => $query->whereNotNull('placed_at'));
}
}
+6 -2
View File
@@ -47,8 +47,12 @@ use Spatie\MediaLibrary\MediaCollections\Models\Media;
* quantity 1, via ProductVariant::canBeFulfilledAtQuantity() (Lunar's own
* purchasability rule: `purchasable === 'always'` is always true regardless of
* stock, `in_stock` checks stock alone, anything else checks stock+backorder).
* Reflects stock as of the last reindex only — nothing currently reindexes a
* product when an order decrements its stock (see docs/product-listing.md).
* Modules\Core\Order\Listeners\DecrementStockOnOrderPlaced reindexes a product
* the moment an order placed against it decrements its stock — see that
* class's own docblock for why only `purchasable === 'in_stock'`
* variants are ever touched. Any other stock edit (a manual admin
* change, a future inventory-sync integration) still only reflects here
* as of the next reindex (see docs/product-listing.md).
*
* - recommendations (recommendations.id filterable): [{id, name, price, image}, ...]
* up to 4 other products to show alongside this one (a "related products"
+43 -7
View File
@@ -2,12 +2,14 @@
namespace Modules\Core\Catalog\Services;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Pagination\LengthAwarePaginator;
use Lunar\Facades\AttributeManifest;
use Lunar\Models\Language;
use Lunar\Models\Product;
use Modules\Core\Catalog\DTOs\ProductFilters;
use Modules\Core\Catalog\DTOs\ProductListingResult;
use Modules\Core\Catalog\Enums\ProductSort;
use Modules\Core\Catalog\Support\ProductDocumentLocalizer;
use Modules\Core\Catalog\Support\ProductFilterBuilder;
/**
@@ -21,20 +23,37 @@ class ProductSearchService
{
public function __construct(
private readonly ProductFilterBuilder $filterBuilder,
private readonly ProductDocumentLocalizer $localizer,
private readonly ProductService $products,
) {}
/**
* Returns the exact same Modules\Core\Catalog\DTOs\ProductListingResult
* ProductService::list() does — a search results page and a category
* listing page consume identically shaped data, one call each. The
* paginator itself carries plain, localized indexed-document arrays
* (not hydrated Product models), same as list().
*
* priceBounds/availableTags are delegated to ProductService's own
* priceSliderBounds()/availableTags() rather than reimplemented here —
* both already accept a $query param for exactly this reason (a search
* page's slider/tag sidebar should reflect only the products search
* actually matched, not the whole catalog).
*
* $filters/$sort apply the exact same semantics ProductService::list()
* uses for collection browsing (same ProductFilterBuilder, same
* ProductSort::toMeilisearchSort()) — a shopper narrowing a text search
* by price/brand/stock gets identical filter behavior to narrowing a
* category listing, since both go through the same Meilisearch `filter`
* clause underneath.
*
* @return Collection<int, Product>
*/
public function search(string $query, ?ProductFilters $filters = null, ?ProductSort $sort = null): Collection
{
public function search(
string $query,
?ProductFilters $filters = null,
?ProductSort $sort = null,
int $perPage = 24,
int $page = 1,
): ProductListingResult {
$options = [
'attributesToSearchOn' => $this->searchableFields(),
'filter' => $this->filterBuilder->build($filters),
@@ -44,9 +63,26 @@ class ProductSearchService
$options['sort'] = [$sort->toMeilisearchSort()];
}
return Product::search($query)
$paginator = Product::search($query)
->options($options)
->get();
->paginateRaw(perPage: $perPage, page: $page);
$data = collect($this->localizer->hitsFrom($paginator))
->map(fn (array $product) => $this->localizer->withLocalizedFields($product))
->all();
$products = new LengthAwarePaginator(
items: $data,
total: $paginator->total(),
perPage: $paginator->perPage(),
currentPage: $paginator->currentPage(),
options: ['path' => LengthAwarePaginator::resolveCurrentPath()],
);
$priceBounds = $this->products->priceSliderBounds($filters, $filters?->minPrice, $filters?->maxPrice, $query);
$availableTags = $this->products->availableTags($filters, $query);
return new ProductListingResult($products, $priceBounds, $availableTags);
}
/**
+16 -74
View File
@@ -2,17 +2,13 @@
namespace Modules\Core\Catalog\Services;
use Illuminate\Contracts\Pagination\LengthAwarePaginator as LengthAwarePaginatorContract;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\App;
use Lunar\Base\AttributeManifest;
use Lunar\FieldTypes\TranslatedText;
use Lunar\Models\Product;
use Modules\Core\Localization\Services\LanguageCache;
use Modules\Core\Catalog\DTOs\PriceSliderBounds;
use Modules\Core\Catalog\DTOs\ProductFilters;
use Modules\Core\Catalog\DTOs\ProductListingResult;
use Modules\Core\Catalog\Enums\ProductSort;
use Modules\Core\Catalog\Support\ProductDocumentLocalizer;
use Modules\Core\Catalog\Support\ProductFilterBuilder;
/**
@@ -27,8 +23,7 @@ use Modules\Core\Catalog\Support\ProductFilterBuilder;
class ProductService
{
public function __construct(
private readonly LanguageCache $languages,
private readonly AttributeManifest $attributes,
private readonly ProductDocumentLocalizer $localizer,
private readonly ProductFilterBuilder $filterBuilder,
) {}
@@ -65,8 +60,8 @@ class ProductService
->options($options)
->paginateRaw(perPage: $perPage, page: $page);
$data = collect($this->hitsFrom($paginator))
->map(fn (array $product) => $this->withLocalizedFields($product))
$data = collect($this->localizer->hitsFrom($paginator))
->map(fn (array $product) => $this->localizer->withLocalizedFields($product))
->all();
$products = new LengthAwarePaginator(
@@ -91,12 +86,20 @@ class ProductService
* alphabetically; Meilisearch's facetDistribution has no defined order
* of its own.
*
* $query defaults to '' (every product, same as list()'s own default
* text query) — same reasoning as priceRange()'s own $query: pass the
* shopper's search text here too so a search page's own tag sidebar
* reflects only the products search actually matched. Public (not
* private, unlike the rest of this listing-only orchestration) so
* ProductSearchService::search() can reuse it directly rather than
* reimplementing the same facet call a second time.
*
* @return array<int, string>
*/
private function availableTags(?ProductFilters $filters): array
public function availableTags(?ProductFilters $filters, string $query = ''): array
{
$filter = $this->filterBuilder->build($filters, exclude: ['tag']);
$tags = $this->rawFacets('tags', $filter)['facetDistribution']['tags'] ?? [];
$tags = $this->rawFacets('tags', $filter, $query)['facetDistribution']['tags'] ?? [];
return collect($tags)->keys()->sort()->values()->all();
}
@@ -287,69 +290,8 @@ class ProductService
->options(['filter' => $filter])
->paginateRaw(perPage: $limit, page: 1);
return collect($this->hitsFrom($paginator))
->map(fn (array $product) => $this->withLocalizedFields($product))
return collect($this->localizer->hitsFrom($paginator))
->map(fn (array $product) => $this->localizer->withLocalizedFields($product))
->all();
}
/**
* Resolves every translated Product attribute's current-locale value from the
* indexer's per-locale `{handle}_{locale}` fields (e.g. `name_el`, `name_en`,
* `seo_title_el`, ...) into a plain `{handle}` key, falling back to the store's
* default language (LanguageCache::defaultLocale()) when the current locale
* has no translation - e.g. a product with no English copy yet still shows its
* Greek name on /en/ rather than rendering blank.
*
* Which handles are translated is read from AttributeManifest - the same
* source Lunar's own ScoutIndexer reads when exploding a TranslatedText
* attribute into `{handle}_{locale}` keys at index time - rather than a fixed
* list, so a store's own custom translated attributes (e.g. `seo_title`) are
* picked up automatically with no change here. The raw per-locale keys are
* then stripped, since once resolved, callers only ever need the one that
* matched the current locale.
*
* Deliberately not config('app.locale') - App::setLocale() overwrites that
* config value on every request, so by request time it's just whatever the
* current locale already is, not a stable fallback.
*/
private function withLocalizedFields(array $product): array
{
$locale = App::getLocale();
$fallbackLocale = $this->languages->defaultLocale();
$availableLocales = $this->languages->availableLocales();
foreach ($this->translatedAttributeHandles() as $handle) {
$product[$handle] = $product[$handle.'_'.$locale] ?? $product[$handle.'_'.$fallbackLocale] ?? null;
foreach ($availableLocales as $availableLocale) {
unset($product[$handle.'_'.$availableLocale]);
}
}
return $product;
}
/**
* @return array<int, string>
*/
private function translatedAttributeHandles(): array
{
return $this->attributes->getSearchableAttributes((new Product)->getMorphClass())
->filter(fn ($attribute) => $attribute->type === TranslatedText::class)
->pluck('handle')
->all();
}
/**
* For the Meilisearch driver, Scout's paginateRaw() puts the whole raw response
* (hits, query, processingTimeMs, ...) in items(), not a plain list of hits - the
* actual documents are under the 'hits' key.
*/
private function hitsFrom(LengthAwarePaginatorContract $paginator): array
{
$rawResponse = $paginator->items();
return collect($rawResponse['hits'] ?? [])->values()->all();
}
}
@@ -0,0 +1,86 @@
<?php
namespace Modules\Core\Catalog\Support;
use Illuminate\Contracts\Pagination\LengthAwarePaginator as LengthAwarePaginatorContract;
use Illuminate\Support\Facades\App;
use Lunar\Base\AttributeManifest;
use Lunar\FieldTypes\TranslatedText;
use Lunar\Models\Product;
use Modules\Core\Localization\Services\LanguageCache;
/**
* Shared between Modules\Core\Catalog\Services\ProductService and
* ProductSearchService — both read the same kind of Meilisearch document
* (Modules\Core\Catalog\Services\ProductIndexer's shape) and need the
* exact same per-locale field resolution and raw-response unwrapping.
* Extracted rather than duplicated so a future fix to the localization-
* fallback logic only needs to be made once.
*/
class ProductDocumentLocalizer
{
public function __construct(
private readonly LanguageCache $languages,
private readonly AttributeManifest $attributes,
) {}
/**
* Resolves every translated Product attribute's current-locale value from the
* indexer's per-locale `{handle}_{locale}` fields (e.g. `name_el`, `name_en`,
* `seo_title_el`, ...) into a plain `{handle}` key, falling back to the store's
* default language (LanguageCache::defaultLocale()) when the current locale
* has no translation - e.g. a product with no English copy yet still shows its
* Greek name on /en/ rather than rendering blank.
*
* Which handles are translated is read from AttributeManifest - the same
* source Lunar's own ScoutIndexer reads when exploding a TranslatedText
* attribute into `{handle}_{locale}` keys at index time - rather than a fixed
* list, so a store's own custom translated attributes (e.g. `seo_title`) are
* picked up automatically with no change here. The raw per-locale keys are
* then stripped, since once resolved, callers only ever need the one that
* matched the current locale.
*
* Deliberately not config('app.locale') - App::setLocale() overwrites that
* config value on every request, so by request time it's just whatever the
* current locale already is, not a stable fallback.
*/
public function withLocalizedFields(array $product): array
{
$locale = App::getLocale();
$fallbackLocale = $this->languages->defaultLocale();
$availableLocales = $this->languages->availableLocales();
foreach ($this->translatedAttributeHandles() as $handle) {
$product[$handle] = $product[$handle.'_'.$locale] ?? $product[$handle.'_'.$fallbackLocale] ?? null;
foreach ($availableLocales as $availableLocale) {
unset($product[$handle.'_'.$availableLocale]);
}
}
return $product;
}
/**
* For the Meilisearch driver, Scout's paginateRaw() puts the whole raw response
* (hits, query, processingTimeMs, ...) in items(), not a plain list of hits - the
* actual documents are under the 'hits' key.
*/
public function hitsFrom(LengthAwarePaginatorContract $paginator): array
{
$rawResponse = $paginator->items();
return collect($rawResponse['hits'] ?? [])->values()->all();
}
/**
* @return array<int, string>
*/
private function translatedAttributeHandles(): array
{
return $this->attributes->getSearchableAttributes((new Product)->getMorphClass())
->filter(fn ($attribute) => $attribute->type === TranslatedText::class)
->pluck('handle')
->all();
}
}
@@ -0,0 +1,20 @@
<?php
namespace Modules\Core\Checkout\Events;
use Lunar\Models\Cart;
/**
* Dispatched by CheckoutService::setRecoveryConsent() every time the
* shopper's promotional/abandoned-cart-recovery opt-in changes — including
* an explicit opt-OUT (a later submit with the checkbox unticked), not
* just an opt-in. $consent is the new value, already written to
* Cart::meta by the time this fires.
*/
class RecoveryConsentSet
{
public function __construct(
public readonly Cart $cart,
public readonly bool $consent,
) {}
}
@@ -0,0 +1,19 @@
<?php
namespace Modules\Core\Checkout\Exceptions;
use RuntimeException;
/**
* Thrown by CheckoutService::initiatePayment() when $termsAccepted is
* false — an Order is a consumer contract, and its acceptance must be
* refused rather than created-then-flagged. No Lunar exception type
* covers this, same reasoning as UnknownPaymentTypeException.
*/
class TermsNotAcceptedException extends RuntimeException
{
public function __construct()
{
parent::__construct('The order cannot be placed until the terms have been accepted.');
}
}
+126 -52
View File
@@ -13,13 +13,16 @@ use Lunar\Models\Cart;
use Modules\Core\Cart\Services\CartService;
use Modules\Core\Checkout\Events\BillingAddressSet;
use Modules\Core\Checkout\Events\PaymentMethodSelected;
use Modules\Core\Checkout\Events\RecoveryConsentSet;
use Modules\Core\Checkout\Events\ShippingAddressSet;
use Modules\Core\Checkout\Events\ShippingOptionSelected;
use Modules\Core\Checkout\Exceptions\InvalidShippingOptionException;
use Modules\Core\Checkout\Exceptions\TermsNotAcceptedException;
use Modules\Core\Checkout\Exceptions\UnknownPaymentTypeException;
use Modules\Core\Payment\DTOs\PaymentResult;
use Modules\Core\Payment\Models\PaymentMethod;
use Modules\Core\Payment\Services\PaymentDriverResolver;
use Modules\Core\Payment\Services\PaymentDriverRegistry;
use Modules\Core\Payment\Services\PaymentMethodCache;
/**
* Storefront-facing checkout operations, mirroring
@@ -42,7 +45,8 @@ class CheckoutService
{
public function __construct(
private readonly CartService $cart,
private readonly PaymentDriverResolver $paymentDrivers,
private readonly PaymentDriverRegistry $paymentDrivers,
private readonly PaymentMethodCache $paymentMethods,
) {}
public function setShippingAddress(array|Addressable $address): Cart
@@ -63,6 +67,49 @@ class CheckoutService
return $cart;
}
/**
* The shopper's promotional/abandoned-cart-recovery opt-in — a
* cart-level decision, deliberately independent of setShippingAddress()/
* setBillingAddress(): consent is given once, and must NOT be reset or
* re-asked just because the shopper later changes which address is on
* the cart (a different Addressable being set is not a withdrawal of
* consent). Only an explicit call to THIS method — the checkbox itself
* being submitted, checked or unchecked — ever changes it; calling it
* again with false is exactly how a later opt-out is recorded.
*
* Stored on Cart::meta (interim, per the legal design this implements —
* a real column/consent record is the eventual target) as
* recovery_consent (bool), recovery_consent_at (ISO 8601 timestamp,
* null when $consent is false), and recovery_consent_policy_version
* (config('legal.privacy_policy_version') at the moment of consent —
* so a later dispute is answered from what was actually agreed to,
* not whatever the policy says today). Separate from any future
* newsletter opt-in — recovery consent is its own scope, never merged
* with marketing-newsletter consent.
*
* Deliberately does not merge with the meta-writing pattern
* selectPaymentMethod() uses (read-merge-save in two separate
* statements) — this writes both meta keys in one save, since there's
* no dependency between recovery_consent and anything else needing to
* be persisted first.
*/
public function setRecoveryConsent(bool $consent): Cart
{
$cart = $this->cart->currentOrCreate();
$cart->meta = [
...($cart->meta?->toArray() ?? []),
'recovery_consent' => $consent,
'recovery_consent_at' => $consent ? now()->toIso8601String() : null,
'recovery_consent_policy_version' => $consent ? config('legal.privacy_policy_version') : null,
];
$cart->save();
Event::dispatch(new RecoveryConsentSet($cart, $consent));
return $cart;
}
/**
* Every shipping option currently available for the cart — already
* fully backed by the merged Shipping-Carriers work: this runs every
@@ -100,54 +147,56 @@ class CheckoutService
}
/**
* Every payment type currently offered to the storefront — every key
* in config('lunar.payments.types') that is BOTH administratively
* enabled (Modules\Core\Payment\Models\PaymentMethod::enabled) AND
* whose registered driver reports itself usable right now
* (Configurable::isConfigured() — e.g. Stripe with no API key set is
* never offered, regardless of the enabled toggle). A type with no
* PaymentMethod row at all (never seeded) is treated as not offered,
* same as disabled — nothing here creates one; see
* InstallLunarCommand::seedPaymentMethods().
* Every payment method currently offered to the storefront, ordered by
* Modules\Core\Payment\Models\PaymentMethod::position — a row is
* offered only when ALL three checks pass, each meaning something
* different to an admin diagnosing why a method isn't showing up (see
* docs/payments.md):
* 1. `enabled` — an admin turned it on.
* 2. its `driver` still resolves via PaymentDriverRegistry — the
* driver class hasn't been removed (see the `payment:sync-drivers`
* command, which sets `driver_missing_at` when this fails; a row
* with that set is excluded here regardless of `enabled`, so a
* vanished driver can never silently look "available").
* 3. the resolved driver reports Configurable::isConfigured() — its
* own runtime requirements (e.g. an API key) are met.
*
* @return array<string>
* @return Collection<int, PaymentMethod>
*/
public function getPaymentMethods(): array
public function getPaymentMethods(): Collection
{
return PaymentMethod::where('enabled', true)
->pluck('type')
->filter(fn (string $type) => $this->paymentDrivers->resolve($type)?->isConfigured() ?? false)
->values()
->all();
return $this->paymentMethods->all()
->filter(fn (PaymentMethod $method) => $method->enabled && $method->driver_missing_at === null)
->filter(fn (PaymentMethod $method) => $this->paymentDrivers->resolve($method->driver)?->isConfigured() ?? false)
->values();
}
/**
* Records which payment type the shopper picked (Cart::meta
* ['payment_method']) — read by e.g. Modules\Core\Payment\Pipelines\
* Cart\ApplyCashOnDeliveryFee to add that type's own cart-total
* adjustments before recalculation.
* ['payment_method']) — read by Modules\Core\Payment\Pipelines\
* Cart\ApplyPaymentMethodFee to add that method's own `data.fee` (if
* any) before recalculation.
*
* Also snapshots Cart::fingerprint() into meta, *after* saving the
* chosen type — the fingerprint has to reflect the final total
* including any payment-type-specific adjustment (e.g. a COD
* surcharge), which only exists once payment_method is set and the
* cart recalculates. Captured here, server-side, rather than asked of
* the storefront: this is the last moment before initiatePayment() that
* the shopper's reviewed total is known, and initiatePayment() reads it
* back internally instead of taking a fingerprint parameter — a
* storefront should never need to know Cart::fingerprint() exists.
* including any payment-method-specific fee, which only exists once
* payment_method is set and the cart recalculates. Captured here,
* server-side, rather than asked of the storefront: this is the last
* moment before initiatePayment() that the shopper's reviewed total is
* known, and initiatePayment() reads it back internally instead of
* taking a fingerprint parameter — a storefront should never need to
* know Cart::fingerprint() exists.
*
* Does not itself call a payment driver — selecting a method and
* initiating payment against it are deliberately separate steps, same
* as selecting a shipping option happens before placing the order.
*
* @throws UnknownPaymentTypeException if $type isn't currently offered
* — see getPaymentMethods() for what that means (registered,
* administratively enabled, and its driver reports itself usable)
* — see getPaymentMethods() for what that means
*/
public function selectPaymentMethod(string $type): Cart
{
if (! in_array($type, $this->getPaymentMethods(), true)) {
if (! $this->getPaymentMethods()->contains('type', $type)) {
throw new UnknownPaymentTypeException($type);
}
@@ -155,7 +204,15 @@ class CheckoutService
$cart->meta = [...($cart->meta?->toArray() ?? []), 'payment_method' => $type];
$cart->save();
$cart = $cart->calculate();
// Cart::calculate() no-ops if this cart instance was already
// calculated earlier in the request (Cart::isCalculated()) — which
// it will have been if the shopper switches payment method after
// the checkout page's first render already calculated it. Without
// recalculate() forcing a fresh run, the just-saved payment_method
// (and any fee tied to it, see ApplyPaymentMethodFee) would never
// be reflected — the summary would keep showing whichever method
// was calculated first.
$cart = $cart->recalculate();
$cart->meta = [...($cart->meta?->toArray() ?? []), 'checkout_fingerprint' => $cart->fingerprint()];
$cart->save();
@@ -170,11 +227,9 @@ class CheckoutService
* Order exists (Cart::createOrder() — confirmed idempotent against a
* cart's own pre-existing, not-yet-placed-at draft; see
* vendor/lunarphp/core/src/Actions/Carts/CreateOrder.php), then
* resolves the payment type selected by selectPaymentMethod() and
* calls pay() or authorize() on its driver, per that type's
* config('lunar.payments.types.{type}.capture_mode') — boboko-core's
* own types (config/payment.php) are merged into that same Lunar
* config key by PaymentServiceProvider::boot().
* resolves the payment method selected by selectPaymentMethod() and
* calls pay() or authorize() on its driver, per that method's own
* `capture_mode` column.
*
* Returns the driver's own PaymentResult UNCHANGED — this method does
* not wait for or resolve anything past what pay()/authorize() itself
@@ -183,14 +238,6 @@ class CheckoutService
* outcome, not an error — the caller (a storefront controller) is
* responsible for whatever the gateway needs next.
*
* KNOWN GAP, explicitly out of scope for now: PaymentResult alone does
* not carry gateway-specific continuation data (e.g. Stripe's
* PaymentIntent client_secret for a Pending result needing frontend
* confirmation) — that concept existed on the deleted PaymentInitiation
* DTO and was intentionally removed from Payment's abstraction layer.
* Nothing here re-introduces it; only OfflinePaymentDriver's
* always-Immediate-Succeeded path is fully wired end-to-end today.
*
* The draft order's own $order->total (not the Cart's) is what gets
* passed as $amount — Order::$total is Lunar's own Price-cast
* attribute, already resolving the correct Currency via the order's
@@ -204,36 +251,63 @@ class CheckoutService
* Same fingerprint precondition the old placeOrder() had: mandatory,
* not optional, checked before the draft is created.
*
* $termsAccepted is likewise mandatory, not optional data a caller
* might omit — an Order is a consumer contract, and its acceptance
* must be refused (TermsNotAcceptedException, before createOrder() is
* ever called — the order is never created-then-flagged) rather than
* assumed. $policyVersion is recorded alongside it on the created
* Order's own meta (terms_accepted, terms_accepted_at,
* terms_accepted_policy_version) — the order-level equivalent of
* setRecoveryConsent()'s cart-level record, and the durable audit
* trail for a later "what did the shopper actually agree to"
* dispute. Written directly here (not via a separate event/listener)
* since the Order row this attaches to doesn't exist before
* createOrder() runs, and nothing else needs to react to this
* specific write independently of the order simply existing.
*
* @param array<string, mixed> $data passed through untouched to
* the driver's pay()/authorize() — e.g. Stripe's payment_method
* token.
*
* @throws UnknownPaymentTypeException if the cart's selected
* payment_method (from selectPaymentMethod()) is no longer offered
* — re-checked here, not just at selection time, since a type could
* be disabled in between
* — re-checked here, not just at selection time, since a method
* could be disabled (or its driver removed) in between
* @throws TermsNotAcceptedException if $termsAccepted is false
* @throws FingerprintMismatchException
* @throws CartException
*/
public function initiatePayment(string $fingerprint, array $data = []): PaymentResult
public function initiatePayment(string $fingerprint, bool $termsAccepted, string $policyVersion, array $data = []): PaymentResult
{
if (! $termsAccepted) {
throw new TermsNotAcceptedException;
}
$cart = $this->cart->currentOrCreate();
$cart->checkFingerprint($fingerprint);
$type = $cart->meta['payment_method'] ?? null;
$method = $type !== null ? $this->getPaymentMethods()->firstWhere('type', $type) : null;
if ($type === null || ! in_array($type, $this->getPaymentMethods(), true)) {
if ($method === null) {
throw new UnknownPaymentTypeException((string) $type);
}
$order = $cart->createOrder();
$driver = $this->paymentDrivers->resolve($type);
$captureMode = config("lunar.payments.types.{$type}.capture_mode", 'pay');
$order->meta = [
...($order->meta?->toArray() ?? []),
'payment_method' => $type,
'terms_accepted' => true,
'terms_accepted_at' => now()->toIso8601String(),
'terms_accepted_policy_version' => $policyVersion,
];
$order->save();
$driver = $this->paymentDrivers->resolve($method->driver);
$context = ['cart_id' => $cart->id, 'order_id' => $order->id];
return $captureMode === 'authorize'
return $method->capture_mode === 'authorize'
? $driver->authorize($type, $order->total, $data, $context)
: $driver->pay($type, $order->total, $data, $context);
}
@@ -0,0 +1,60 @@
<?php
namespace Modules\Core\Command;
use Illuminate\Console\Command;
use Lunar\Models\ProductVariant;
/**
* One-off backfill for variants the Shopify import left with a blank SKU —
* not an importer bug, the source CSV rows genuinely had no `Variant SKU`
* value (see Modules\MigrateImport\Shopify\ShopifyExportImporter) — so
* this synthesizes one instead of re-running the import. Format is
* "SKU-P{product_id}-V{variant_id}": deterministic and guaranteed unique
* without a uniqueness check, since product_id/variant_id already are.
* Only variants with a null `sku` are touched.
*/
class BackfillMissingSkusCommand extends Command
{
protected $signature = 'boboko:catalog:backfill-skus {--dry-run : List what would change without writing}';
protected $description = 'Generate a SKU for every product variant that is missing one';
public function handle(): void
{
$dryRun = (bool) $this->option('dry-run');
$query = ProductVariant::query()->whereNull('sku');
$total = $query->count();
if ($total === 0) {
$this->info('No variants are missing a SKU.');
return;
}
$this->info(($dryRun ? '[dry-run] ' : '') . "Backfilling SKUs for {$total} variant(s)...");
$bar = $this->output->createProgressBar($total);
$bar->start();
$query->chunkById(500, function ($variants) use ($dryRun, $bar) {
foreach ($variants as $variant) {
$sku = "SKU-P{$variant->product_id}-V{$variant->id}";
if ($dryRun) {
$this->newLine();
$this->line("Variant {$variant->id}: sku => {$sku}");
} else {
$variant->update(['sku' => $sku]);
}
$bar->advance();
}
});
$bar->finish();
$this->newLine();
$this->info($dryRun ? 'Dry run complete — no changes were written.' : 'Done.');
}
}
+28 -25
View File
@@ -284,35 +284,38 @@ class InstallLunarCommand extends Command
}
/**
* Per-type skip-if-exists, same idempotent convention as
* seedStorefrontLabels() — a type already present (including one an
* admin has since edited via the Filament Payment Methods resource) is
* left untouched. Safe to re-run after a new payment type is added to
* config('lunar.payments.types') (e.g. installing a Stripe/Nexi
* package), which is the whole reason this isn't a one-time-only seed.
* A single, deliberately opinionated starter row on fresh install —
* `PaymentMethod` is now fully admin-creatable/deletable (see
* docs/payments.md), so this is no longer "seed every config-defined
* type," it's "give a fresh store one reasonable payment method to
* start from instead of zero." Every value here is a plain literal in
* THIS command, not sourced from config or PaymentDriverRegistry — a
* driver has no business carrying opinions about what its captured
* order status should be called; that's a merchant decision.
*
* Seeded disabled — a newly-seeded row (whether from this store's
* initial install, or a payment provider package installed later)
* shouldn't go live for shoppers before staff have actually reviewed
* it (real credentials configured, a fee set, etc.) and turned it on
* via the Payment Methods resource. See CheckoutService::
* getPaymentMethods(), which only offers a type once both 'enabled'
* here and its driver's own isConfigured() check pass.
* Skip-if-exists on `type`, same idempotent convention as
* seedStorefrontLabels() — an admin who has since edited or deleted
* this row (via the Filament Payment Methods resource) is left alone;
* re-running lunar:install never recreates a deleted starter row.
*
* Seeded disabled — shouldn't go live for shoppers before staff have
* actually reviewed it and turned it on via the Payment Methods
* resource. See CheckoutService::getPaymentMethods().
*/
private function seedPaymentMethods(): void
{
$existingTypes = PaymentMethod::pluck('type');
foreach (array_keys(config('lunar.payments.types', [])) as $type) {
if ($existingTypes->contains($type)) {
continue;
}
PaymentMethod::create([
'type' => $type,
'enabled' => false,
'data' => [],
]);
if (PaymentMethod::where('type', 'cash-on-delivery')->exists()) {
return;
}
PaymentMethod::create([
'type' => 'cash-on-delivery',
'name' => 'Cash on Delivery',
'driver' => 'cash-on-delivery',
'capture_mode' => 'pay',
'position' => 0,
'enabled' => false,
'data' => [],
]);
}
}
+52
View File
@@ -0,0 +1,52 @@
<?php
namespace Modules\Core\Command;
use Illuminate\Console\Command;
use Modules\Core\Payment\Models\PaymentMethod;
use Modules\Core\Payment\Services\PaymentDriverRegistry;
/**
* Reconciles every Modules\Core\Payment\Models\PaymentMethod row's `driver`
* column against PaymentDriverRegistry — the registry only knows "which
* driver classes exist THIS deploy," and only at the moment something
* calls resolve(); nothing else notices a driver disappearing (a package
* removed, a custom Registry::register() call deleted) on its own. Meant
* to run unconditionally on every container start/deploy (alongside
* `migrate`), not on a schedule — "did the set of registered drivers
* change" is a deploy-time event, cheap enough to check every single time
* regardless of whether anything actually changed. See docs/payments.md.
*
* Sets/clears `driver_missing_at` — deliberately NOT the `enabled` column,
* so an admin's own manual toggle is never confused with "the driver
* vanished," and a driver that comes back in a later deploy auto-clears
* this with no admin action needed.
*/
class SyncPaymentDriversCommand extends Command
{
protected $signature = 'boboko:payment:sync-drivers';
protected $description = 'Flag PaymentMethod rows whose driver no longer resolves via the registry, and clear the flag for ones that do again';
public function handle(PaymentDriverRegistry $registry): int
{
$missing = 0;
$restored = 0;
PaymentMethod::query()->each(function (PaymentMethod $method) use ($registry, &$missing, &$restored) {
$resolves = $method->driver !== null && $registry->resolve($method->driver) !== null;
if (! $resolves && $method->driver_missing_at === null) {
$method->update(['driver_missing_at' => now()]);
$missing++;
} elseif ($resolves && $method->driver_missing_at !== null) {
$method->update(['driver_missing_at' => null]);
$restored++;
}
});
$this->components->info("Payment driver sync complete: {$missing} newly flagged, {$restored} restored.");
return self::SUCCESS;
}
}
+13 -4
View File
@@ -3,6 +3,7 @@
namespace Modules\Core;
use Lunar\Admin\Filament\Resources\OrderResource\Pages\ManageOrder;
use Lunar\Admin\Filament\Resources\OrderResource\Pages\Components\OrderItemsTable;
use Filament\Contracts\Plugin;
use Filament\Panel;
use Illuminate\Database\Eloquent\Relations\HasMany;
@@ -25,13 +26,19 @@ use Modules\Core\Cart\Filament\Resources\CartResource;
use Modules\Core\Catalog\Filament\Extensions\ProductOptionResourceExtension;
use Modules\Core\Catalog\Filament\Extensions\ValuesRelationManagerExtension;
use Modules\Core\Localization\Filament\Resources\LanguageLineResource;
use Modules\Core\Order\Filament\Extensions\OrderItemsTableExtension;
use Modules\Core\Order\Filament\Extensions\OrderPaymentMethodSummaryExtension;
use Modules\Core\Order\Filament\Extensions\OrderActionsExtension;
use Modules\Core\Order\Filament\Extensions\OrderTransactionsExtension;
use Modules\Core\Payment\Filament\Resources\PaymentMethodResource;
use Modules\Core\Review\Filament\Extensions\ProductResourceExtension;
use Modules\Core\Review\Models\ProductReview;
use Modules\Core\Shipping\Extensions\OrderShipmentsExtension;
use Modules\Core\Shipping\Extensions\OrderViewExtension;
use Modules\Core\Shipping\Extensions\ShippingMethodListExtension;
use Modules\Core\Shipping\Extensions\ShippingMethodResourceExtension;
use Modules\Core\Shipping\Filament\Pages\ManagePickupManifests;
use Modules\Core\Shipping\Filament\Resources\ManifestResource;
use Modules\Core\Shipping\Filament\Resources\ShipmentResource;
class CorePlugin implements Plugin
{
@@ -51,9 +58,10 @@ class CorePlugin implements Plugin
LanguageLineResource::class,
CartResource::class,
PaymentMethodResource::class,
ShipmentResource::class,
ManifestResource::class,
])
->plugin(ShippingPlugin::make())
->pages([ManagePickupManifests::class]);
->plugin(ShippingPlugin::make());
LunarPanel::extensions([
StaffResource::class => StaffResourceExtension::class,
@@ -62,7 +70,8 @@ class CorePlugin implements Plugin
ValuesRelationManager::class => ValuesRelationManagerExtension::class,
ShippingMethodResource::class => ShippingMethodResourceExtension::class,
ListShippingMethod::class => ShippingMethodListExtension::class,
ManageOrder::class => OrderViewExtension::class,
ManageOrder::class => [OrderViewExtension::class, OrderActionsExtension::class, OrderTransactionsExtension::class, OrderPaymentMethodSummaryExtension::class, OrderShipmentsExtension::class],
OrderItemsTable::class => OrderItemsTableExtension::class,
]);
Product::macro('reviews', function (): HasMany {
@@ -24,6 +24,7 @@ class StorefrontLabels
'nav.account' => ['en' => 'Account', 'el' => 'Λογαριασμός'],
'nav.back' => ['en' => 'Back', 'el' => 'Πίσω'],
'nav.contact' => ['en' => 'Contact', 'el' => 'Επικοινωνία'],
'nav.close' => ['en' => 'Close', 'el' => 'Κλείσιμο'],
'cart.empty' => ['en' => 'Your cart is empty', 'el' => 'Το καλάθι σας είναι άδειο'],
'cart.checkout' => ['en' => 'Checkout', 'el' => 'Ολοκλήρωση Παραγγελίας'],
'cart.total' => ['en' => 'Total', 'el' => 'Σύνολο'],
@@ -38,6 +39,7 @@ class StorefrontLabels
'auth.login' => ['en' => 'Log In', 'el' => 'Σύνδεση'],
'auth.logout' => ['en' => 'Log Out', 'el' => 'Αποσύνδεση'],
'search.placeholder' => ['en' => 'Search products…', 'el' => 'Αναζήτηση προϊόντων…'],
'search.results_for' => ['en' => 'Search results for ', 'el' => 'Αποτελέσματα αναζήτησης για '],
'customer_reviews' => [
'en' => '{0} No customer reviews|{1} :count customer review|[2,*] :count customer reviews',
'el' => '{0} Καμία αξιολόγηση πελάτη|{1} :count αξιολόγηση πελάτη|[2,*] :count αξιολογήσεις πελατών',
@@ -66,6 +68,7 @@ class StorefrontLabels
'en' => '{0} No products found|{1} Showing :first–:last of :total result|[2,*] Showing :first–:last of :total results',
'el' => '{0} Δεν βρέθηκαν προϊόντα|{1} Εμφάνιση :first–:last από :total αποτέλεσμα|[2,*] Εμφάνιση :first–:last από :total αποτελέσματα',
],
'shop.all_products' => ['en' => 'All Products', 'el' => 'Όλα τα Προϊόντα'],
'shop.sort_label' => ['en' => 'Sort products', 'el' => 'Ταξινόμηση προϊόντων'],
'shop.sort_default' => ['en' => 'Default sorting', 'el' => 'Προεπιλεγμένη ταξινόμηση'],
'shop.sort_popularity' => ['en' => 'Popularity', 'el' => 'Δημοφιλή'],
@@ -0,0 +1,68 @@
<?php
namespace Modules\Core\Order\Commands;
use Illuminate\Console\Command;
use Lunar\Models\Order;
use Modules\Core\Order\Events\OrderCompleted;
use Modules\Core\Order\Services\OrderStatusWriter;
/**
* Auto-completes a carrier order once its 14-day return window has
* elapsed with no return requested — the automatic counterpart to the
* staff "Update Status" action's manual completion. Store-pickup orders
* have no return-window step at all (Modules\Core\Order\Listeners\
* CompleteOrderOnPickedUp completes them immediately), so this only ever
* touches carrier orders sitting in 'delivered' (the status also carrying
* "return window is open" — see AdvanceFulfillmentOnDelivered).
*
* Registered at exactly dailyAt('00:00') in
* Modules\Core\Providers\OrderServiceProvider — a compliance requirement
* that this run at exact midnight, not Laravel's own arbitrary default
* time for a plain daily() schedule.
*
* "When did the window open" is read from order_status_transitions rather
* than Order::updated_at, which any unrelated field write would bump —
* this is the concrete reason the audit table exists beyond pure logging.
*
* Window length is config('core.order.return_window_days') — a legal/
* policy value a store may need to change without a code deploy, not a
* hardcoded constant.
*/
class CloseExpiredReturnWindows extends Command
{
protected $signature = 'boboko:order:close-expired-return-windows';
protected $description = 'Auto-complete carrier orders whose return window has elapsed with no return requested.';
public function handle(OrderStatusWriter $writer): void
{
$cutoff = now()->subDays(config('core.order.return_window_days', 14));
$orderIds = Order::query()
->where('status', 'delivered')
->whereHas('statusTransitions', function ($query) use ($cutoff) {
$query->where('to_status', 'delivered')
->where('created_at', '<=', $cutoff);
})
->pluck('id');
$completed = 0;
foreach ($orderIds as $orderId) {
$order = Order::find($orderId);
if (! $order || $order->status !== 'delivered') {
continue; // idempotent no-op — moved on since the query ran
}
$writer->write($order, 'completed', self::class);
OrderCompleted::dispatch($order);
$completed++;
}
$this->components->info("Completed {$completed} order(s) past their return window.");
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace Modules\Core\Order\DTOs;
/**
* What a Modules\Core\Order\Services\OrderFulfillmentService method
* returns instead of throwing/echoing a Filament notification directly —
* keeps that service usable outside a Filament action (a future API
* endpoint, a console command, a test) without dragging
* Filament\Notifications\Notification along. Modules\Core\Shipping\
* Extensions\OrderViewExtension is the one place that translates this
* into an actual on-screen notification.
*/
final class OrderFulfillmentResult
{
private function __construct(
public readonly bool $success,
public readonly string $message,
) {}
public static function success(string $message): self
{
return new self(true, $message);
}
public static function failure(string $message): self
{
return new self(false, $message);
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace Modules\Core\Order\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Lunar\Models\Order;
/**
* The one terminal signal every notification/reporting concern that only
* cares about "this order is fully done" should listen to, regardless of
* which path actually got it there — dispatched by all four:
* Modules\Core\Order\Listeners\CompleteOrderOnPickedUp (store-pickup),
* Modules\Core\Order\Commands\CloseExpiredReturnWindows (carrier,
* automatic 14-day return-window expiry), or Modules\Core\Shipping\
* Extensions\OrderViewExtension's "Mark Completed" action (manual
* universal fallback, either branch).
*/
class OrderCompleted
{
use Dispatchable;
public function __construct(
public readonly Order $order,
) {}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace Modules\Core\Order\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Lunar\Models\Order;
use Modules\Core\Shipping\Models\ShipmentInfo;
/**
* Dispatched by either of the two paths that move a carrier order's
* `status` to 'dispatched' — Modules\Core\Order\Listeners\
* AdvanceFulfillmentOnCarrierCheckpoint (automatic, reacting to a real
* carrier checkpoint) or Modules\Core\Order\Services\
* OrderFulfillmentService::createShipmentAndDispatch() (staff-driven, via
* the single "Update Status" action). $shipmentInfo is nullable
* specifically because of that second path — populated with the
* triggering checkpoint when it's real, null when staff drove it
* manually. Mirrors OrderDelivered's {order, shipmentInfo} shape, just
* with the nullability this one event additionally needs.
*/
class OrderDispatched
{
use Dispatchable;
public function __construct(
public readonly Order $order,
public readonly ?ShipmentInfo $shipmentInfo = null,
) {}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace Modules\Core\Order\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Lunar\Models\Order;
/**
* Dispatched by Modules\Core\Order\Services\OrderStatusWriter::markPaid()
* whenever Order::paid flips to true — entirely independent of the
* `status` column (see OrderStatusFlow's own docblock for why payment
* timing, especially for cash-on-delivery, cannot be modeled as a step in
* that sequence). Order::status changes are instead picked up generically
* by Modules\Core\Order\Events\OrderStatusUpdated (dispatched by
* OrderObserver whenever `status` changes, regardless of writer).
*/
class OrderPaidChanged
{
use Dispatchable;
public function __construct(
public readonly Order $order,
public readonly string $causeClass,
) {}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace Modules\Core\Order\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Lunar\Models\Order;
/**
* Dispatched by Modules\Core\Order\Services\OrderFulfillmentService::
* markPickedUp(), the staff-driven "Update Status" action's handling of
* the 'picked_up' target — the customer has collected a store-pickup
* order in person. Store-pickup only; a carrier order's equivalent
* "arrived" moment is OrderDelivered. Modules\Core\Order\Listeners\
* CompleteOrderOnPickedUp reacts to this by moving `status` straight to
* 'completed' — no return-window step for store-pickup, per the business
* design.
*/
class OrderPickedUp
{
use Dispatchable;
public function __construct(
public readonly Order $order,
) {}
}
@@ -0,0 +1,23 @@
<?php
namespace Modules\Core\Order\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Lunar\Models\Order;
/**
* Dispatched by Modules\Core\Shipping\Extensions\OrderViewExtension's
* "Mark Ready" action, carrier branch (Order::isStorePickupOrder() ===
* false) — staff has packed/staged the order for carrier handoff.
* Staff-internal: nothing customer-facing happens at this moment, so no
* notification listens to this event (compare OrderReadyForPickup, which
* does trigger a customer email).
*/
class OrderReadyForDispatch
{
use Dispatchable;
public function __construct(
public readonly Order $order,
) {}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace Modules\Core\Order\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Lunar\Models\Order;
/**
* Dispatched by Modules\Core\Shipping\Extensions\OrderViewExtension's
* "Mark Ready" action, store-pickup branch (Order::isStorePickupOrder()
* === true) — staff has packed/staged the order for the customer to
* collect in store. Drives Modules\Core\Order\Notifications\
* OrderPickupReadyNotification ("come collect your order").
*/
class OrderReadyForPickup
{
use Dispatchable;
public function __construct(
public readonly Order $order,
) {}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace Modules\Core\Order\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Lunar\Models\Order;
/**
* Dispatched by Modules\Core\Order\Services\OrderStatusWriter::write()
* alongside the generic Modules\Core\Order\Events\OrderStatusUpdated
* (which Modules\Core\Order\Observers\OrderObserver dispatches for ANY
* `status` write, regardless of cause, and which
* OrderStatusUpdatedNotification already listens to). This event exists
* only because the audit trail (Modules\Core\Order\Listeners\
* RecordStatusTransition) needs $causeClass, which OrderStatusUpdated
* does not carry — OrderStatusWriter is the only writer of `status` this
* package has left, so it's the only place that needs to know its own
* cause.
*/
class OrderStatusChanged
{
use Dispatchable;
public function __construct(
public readonly Order $order,
public readonly ?string $previousStatus,
public readonly string $newStatus,
public readonly string $causeClass,
) {}
}
@@ -0,0 +1,210 @@
<?php
namespace Modules\Core\Order\Filament\Extensions;
use Filament\Actions\Action;
use Filament\Forms\Components\Select;
use Filament\Notifications\Notification;
use Filament\Support\Exceptions\Halt;
use Lunar\Admin\Support\Extending\ViewPageExtension;
use Lunar\Models\Transaction;
use Modules\Core\Payment\Contracts\SupportsRefunds;
use Modules\Core\Payment\Models\CoreTransaction;
use Modules\Core\Payment\Services\PaymentDriverRegistry;
use Modules\Core\Payment\Support\TransactionDriverAdapter;
use ReflectionProperty;
/**
* Fixes a real bug in Lunar's own admin panel, not anything specific to how
* boboko resolves payment drivers: ManageOrder::getRefundAction() and
* ::getCaptureAction() (vendor/lunarphp/lunar/.../ManageOrder.php) both
* report a failed refund/capture by calling, in this order:
* $action->failureNotification(...); $action->failure(); $action->halt();
* but Filament\Actions\Concerns\InteractsWithActions::callMountedAction()
* only ever calls sendFailureNotification() from a match($action->getStatus())
* block that runs AFTER the action's call() returns normally — halt() throws
* Filament\Support\Exceptions\Halt, which is caught in an earlier catch block
* that rolls back the DB transaction and returns null, never reaching that
* match block. So the notification set via failureNotification() is built
* but never sent: the admin sees the modal just close/reset with no
* indication anything happened. This was always broken in Lunar; it was
* invisible before because nothing in this codebase's Transaction::driver()
* could return a real, honest failure — see Payment\Support\
* TransactionDriverAdapter's own docblock for that history.
*
* Fix, for refund: same notification fix, but the action() closure is
* replaced outright (not wrapped) rather than reused, because refund also
* needs a "Refund via" driver Select added to the modal (see
* fixRefundAction()) and the actual call routed through
* Payment\Support\TransactionDriverAdapter::refundVia() instead of
* Lunar\Models\Transaction::refund() — see fixRefundAction()'s own
* docblock.
*
* Fix, for capture: same notification fix, but the action() closure is
* also replaced outright — the actual call is routed through
* Payment\Support\TransactionDriverAdapter::capture() instead of
* Lunar\Models\Transaction::capture() (see fixCaptureAction()), so a
* manual backoffice capture goes through the app's own payment driver
* registry and dispatches Payment\Events\PaymentCaptured exactly like a
* checkout-time capture does — the vendor path resolved
* Lunar\Facades\Payments (an entirely separate, unused driver registry)
* and never dispatched that event, which is why Order::status used to
* stay stuck on 'awaiting_payment' after a manual capture even though
* Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus now advances it
* on PaymentCaptured.
*/
class OrderActionsExtension extends ViewPageExtension
{
public function headerActions(array $actions): array
{
return array_map(
fn (Action $action) => match ($action->getName()) {
'refund' => $this->fixRefundAction($action),
'capture' => $this->fixCaptureAction($action),
default => $action,
},
$actions,
);
}
/**
* Combines both refund-only changes on top of the failure-notification
* fix every action here gets: adds a "Refund via" driver Select
* (defaulting to the transaction's own driver) to the modal, and
* replaces the actual refund call with one that honours that field —
* calling Payment\Support\TransactionDriverAdapter::refundVia()
* directly (bypassing Lunar\Models\Transaction::refund(), whose fixed
* refund(int $amount, $notes = null) signature has no room for a
* driver override) whenever the admin picked a driver other than the
* transaction's own. When left at the default, behaviour is identical
* to calling $transaction->refund() — refundVia() resolves to the same
* driver either way.
*
* The Select is appended to Lunar's own schema closure (read via
* reflection — HasSchema::$schema has no public getter) rather than
* replacing it outright, so the transaction/amount/notes/confirm
* fields Lunar already built are untouched.
*/
private function fixRefundAction(Action $action): Action
{
$originalSchema = $this->readProtectedProperty($action, 'schema');
$action->schema(function (array $arguments) use ($action, $originalSchema) {
$fields = is_callable($originalSchema)
? $action->evaluate($originalSchema, $arguments)
: ($originalSchema ?? []);
return [
...$fields,
Select::make('driver')
->label('Refund via')
->options(fn () => $this->refundCapableDriverLabels())
->default(fn ($get) => $this->driverKeyForTransaction($get('transaction')))
->native(false)
->required(),
];
});
return $action->action(function (array $data, Action $action) {
$transaction = Transaction::find($data['transaction']);
if (! $transaction instanceof CoreTransaction) {
$action->failureNotification(fn () => Notification::make('refund_failure')->danger()->title('Transaction not found.'))
->sendFailureNotification();
throw new Halt;
}
$adapter = app(TransactionDriverAdapter::class);
$driverKey = $data['driver'] ?? $adapter->driverKeyFor($transaction);
$response = $adapter->refundVia($transaction, $driverKey, (int) bcmul((string) $data['amount'], (string) $transaction->order->currency->factor), $data['notes'] ?? null);
if (! $response->success) {
$action->failureNotification(
fn () => Notification::make('refund_failure')->color('danger')->title($response->message)
)->sendFailureNotification();
throw new Halt;
}
$action->success();
});
}
/**
* Mirrors fixRefundAction()'s notification fix, but for the "amount"
* field already on the vendor schema — no extra field needed, since
* capture always goes back through the transaction's own original
* driver (there's no equivalent to refunding via a different driver).
*/
private function fixCaptureAction(Action $action): Action
{
return $action->action(function (array $data, Action $action) {
$transaction = Transaction::find($data['transaction']);
if (! $transaction instanceof CoreTransaction) {
$action->failureNotification(fn () => Notification::make('capture_failure')->danger()->title('Transaction not found.'))
->sendFailureNotification();
throw new Halt;
}
$response = app(TransactionDriverAdapter::class)->capture(
$transaction,
(int) bcmul((string) $data['amount'], (string) $transaction->order->currency->factor),
);
if (! $response->success) {
$action->failureNotification(
fn () => Notification::make('capture_failure')->color('danger')->title($response->message)
)->sendFailureNotification();
throw new Halt;
}
$action->success();
});
}
/**
* @return array<string, string>
*/
private function refundCapableDriverLabels(): array
{
$registry = app(PaymentDriverRegistry::class);
$labels = [];
foreach ($registry->all() as $key => $driverClass) {
if (app($driverClass) instanceof SupportsRefunds) {
$labels[$key] = $registry->label($key) ?? $key;
}
}
return $labels;
}
private function driverKeyForTransaction(mixed $transactionId): ?string
{
if (blank($transactionId)) {
return null;
}
$transaction = Transaction::find($transactionId);
if (! $transaction instanceof CoreTransaction) {
return null;
}
return app(TransactionDriverAdapter::class)->driverKeyFor($transaction);
}
private function readProtectedProperty(object $object, string $property): mixed
{
$reflected = new ReflectionProperty($object, $property);
$reflected->setAccessible(true);
return $reflected->getValue($object);
}
}
@@ -0,0 +1,50 @@
<?php
namespace Modules\Core\Order\Filament\Extensions;
use Filament\Actions\BulkAction;
use Filament\Support\Exceptions\Halt;
use Filament\Tables\Table;
use Lunar\Admin\Support\Extending\BaseExtension;
/**
* Same fix as OrderActionsExtension, applied to the order lines
* table's "bulk_refund" toolbar action (Lunar\Admin\...\OrderItemsTable::
* getBulkRefundAction()) — see that class's docblock for the underlying
* Filament bug (failureNotification()+failure()+halt() never actually
* sends the notification, because halt()'s Halt exception is caught before
* Filament reaches the code that would send it).
*/
class OrderItemsTableExtension extends BaseExtension
{
public function extendTable(Table $table): Table
{
return $table->toolbarActions(
array_map(
fn ($action) => $action instanceof BulkAction && $action->getName() === 'bulk_refund'
? $this->fixFailureNotification($action)
: $action,
$table->getToolbarActions(),
),
);
}
private function fixFailureNotification(BulkAction $action): BulkAction
{
$originalAction = $action->getActionFunction();
if ($originalAction === null) {
return $action;
}
return $action->action(function (array $arguments) use ($action, $originalAction) {
try {
return $action->evaluate($originalAction, $arguments);
} catch (Halt $exception) {
$action->sendFailureNotification();
throw $exception;
}
});
}
}
@@ -0,0 +1,47 @@
<?php
namespace Modules\Core\Order\Filament\Extensions;
use Filament\Infolists\Components\TextEntry;
use Lunar\Admin\Support\Extending\ViewPageExtension;
use Lunar\Models\Order;
use Modules\Core\Payment\Models\PaymentMethod;
/**
* Adds a "Payment Method" entry to the order summary sidebar — previously
* nowhere on the order page told staff which payment method a shopper
* actually used. Reads Order.meta['payment_method'] (written by
* Modules\Core\Checkout\Services\CheckoutService::initiatePayment()), the
* same source Modules\Core\Order\Services\OrderStatusFlow::isCod() reads,
* so this entry and the "Mark Paid" action's visibility always agree on
* what payment method an order used. Falls back to the most recent
* Transaction.driver for an order placed before that field existed.
*
* Uses the extendOrderSummarySchema hook, same as the deleted 3-axis
* OrderStatusSummaryExtension did — see that class's git history for the
* hook's own docblock/rationale.
*/
class OrderPaymentMethodSummaryExtension extends ViewPageExtension
{
public function extendOrderSummarySchema(array $schema): array
{
$schema[] = TextEntry::make('payment_method')
->label('Payment method')
->state(fn (Order $record) => $this->resolveLabel($record))
->placeholder('—')
->alignEnd();
return $schema;
}
private function resolveLabel(Order $record): ?string
{
$type = $record->meta['payment_method'] ?? $record->transactions()->latest('id')->value('driver');
if ($type === null) {
return null;
}
return PaymentMethod::where('type', $type)->value('name') ?? $type;
}
}
@@ -0,0 +1,33 @@
<?php
namespace Modules\Core\Order\Filament\Extensions;
use Filament\Infolists\Components\RepeatableEntry;
use Lunar\Admin\Support\Extending\ViewPageExtension;
use Modules\Core\Order\Filament\Infolists\TransactionEntry;
/**
* Swaps Lunar\Admin\Support\Infolists\Components\Transaction for our own
* TransactionEntry in the order page's transactions list — same component,
* different Blade view, so a Transaction.meta['notes'] value (written by
* a manual/attested driver like Payment\Drivers\BankTransferPaymentDriver)
* actually renders somewhere, instead of only the notes column Lunar's own
* view reads (see TransactionEntry's own docblock for why that column is
* usually empty for a successful manual payment/refund).
*
* Uses the extendTransactionsRepeatableEntry hook ManageOrder's own
* DisplaysTransactions trait already calls
* (getTransactionsRepeatableEntry() → callStaticLunarHook(
* 'extendTransactionsRepeatableEntry', ...)) — a class/component swap via
* a Lunar-provided hook, the same category of extension already used
* throughout CorePlugin, not a Blade view-path override.
*/
class OrderTransactionsExtension extends ViewPageExtension
{
public function extendTransactionsRepeatableEntry(RepeatableEntry $entry): RepeatableEntry
{
return $entry->schema([
TransactionEntry::make('transaction_detail'),
]);
}
}
@@ -0,0 +1,30 @@
<?php
namespace Modules\Core\Order\Filament\Infolists;
use Lunar\Admin\Support\Infolists\Components\Transaction as LunarTransactionEntry;
/**
* Same component as Lunar's own Transaction infolist entry — only the
* Blade view differs, to also show Transaction.meta['notes'] (what
* Payment\Drivers\BankTransferPaymentDriver and any other manual/attested
* driver write a staff-entered note into — see that driver's own
* docblock) when the notes column itself is empty. The notes column is
* populated by Order\Services\TransactionRecorder from
* PaymentResult::$failureReason, which is only ever set on a FAILED
* result — a successful manual payment/refund's note would otherwise be
* recorded (Transaction.meta) but never shown anywhere in the admin
* panel, since Lunar's own view only ever reads the notes column.
*
* Registered in place of Lunar's own Transaction component via
* Order\Filament\Extensions\OrderTransactionsExtension's
* extendTransactionsRepeatableEntry() hook (see that class), not a
* view-path override — this is the same "swap the concrete
* class/component" pattern already used throughout CorePlugin
* (LunarPanel::extensions()), rather than shadowing Lunar's Blade file
* from underneath it.
*/
class TransactionEntry extends LunarTransactionEntry
{
protected string $view = 'core::order.infolists.transaction';
}
@@ -0,0 +1,49 @@
<?php
namespace Modules\Core\Order\Listeners;
use Modules\Core\Order\Events\OrderDispatched;
use Modules\Core\Order\Services\OrderStatusWriter;
use Modules\Core\Shipping\Enums\TrackingStatus;
use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier;
/**
* The automatic half of "Dispatched" — the manual fallback is the staff
* "Update Status" action (Modules\Core\Shipping\Extensions\
* OrderViewExtension). Listens to ShipmentStatusUpdatedByCarrier directly,
* the same event Modules\Core\Order\Listeners\DeriveOrderDeliveredFromShipment
* listens to.
*
* Reacts to either TrackingStatus::CollectedFromSender (the carrier
* collected the parcel from the merchant) or InTransit directly, for a
* carrier that skips straight there without a distinct collection
* checkpoint.
*
* Guarded to only fire from 'ready_for_dispatch' — a late/duplicate
* checkpoint, or an order the manual action already advanced, is a
* silent no-op.
*/
class AdvanceFulfillmentOnCarrierCheckpoint
{
public function __construct(
private readonly OrderStatusWriter $writer,
) {}
public function handle(ShipmentStatusUpdatedByCarrier $event): void
{
if ($event->shipmentInfo->status !== TrackingStatus::InTransit
&& $event->shipmentInfo->status !== TrackingStatus::CollectedFromSender) {
return;
}
$order = $event->shipmentInfo->shipment->order;
if (! $order || $order->status !== 'ready_for_dispatch') {
return;
}
$this->writer->write($order, 'dispatched', self::class);
OrderDispatched::dispatch($order, $event->shipmentInfo);
}
}
@@ -0,0 +1,43 @@
<?php
namespace Modules\Core\Order\Listeners;
use Modules\Core\Order\Events\OrderDelivered;
use Modules\Core\Order\Services\OrderStatusWriter;
/**
* Writes `status` to 'delivered' once a carrier confirms delivery, rather
* than jumping straight to 'completed'. Carrier orders get a return
* window between delivery and completion (see Modules\Core\Order\
* Commands\CloseExpiredReturnWindows, which auto-completes an order once
* that window elapses) — 'delivered' is both "the parcel arrived" and
* "the return window is now open"; nothing distinguishes those as
* separate instants, they're the same moment, so there is only the one
* status value.
*
* Kept separate from Modules\Core\Order\Listeners\
* DeriveOrderDeliveredFromShipment, which only ever dispatches
* OrderDelivered — deriving "was this delivered" and acting on it by
* writing `status` are deliberately two different listeners.
*
* Guarded to only fire from 'dispatched' — a duplicate/late Delivered
* checkpoint, or an order a manual action already moved past, is a
* silent no-op.
*/
class AdvanceFulfillmentOnDelivered
{
public function __construct(
private readonly OrderStatusWriter $writer,
) {}
public function handle(OrderDelivered $event): void
{
$order = $event->order;
if ($order->status !== 'dispatched') {
return;
}
$this->writer->write($order, 'delivered', self::class);
}
}
@@ -5,34 +5,53 @@ namespace Modules\Core\Order\Listeners;
use Illuminate\Support\Facades\Event;
use Lunar\Models\Order;
use Modules\Core\Checkout\Events\OrderPlaced;
use Modules\Core\Order\Enums\PaymentStatus;
use Modules\Core\Order\Services\OrderStatusFlow;
use Modules\Core\Order\Services\OrderStatusWriter;
use Modules\Core\Order\Support\OrderStatus;
use Modules\Core\Payment\Events\PaymentAuthorized;
use Modules\Core\Payment\Events\PaymentCaptured;
use Modules\Core\Payment\Events\PaymentRefunded;
/**
* The only place an Order's status column is written in reaction to a
* payment outcome. Registered against BOTH PaymentCaptured and
* PaymentAuthorized (see OrderServiceProvider) — same handler either way,
* since both carry the same {type, result, context} shape and only differ
* in which config key decides the resulting status.
* Registered against PaymentCaptured, PaymentAuthorized, AND
* PaymentRefunded (see OrderServiceProvider).
*
* PaymentCaptured writes both Order::paid/paid_at (via
* OrderStatusWriter::markPaid()) AND advances `status` out of
* 'awaiting_payment' to the next step in the order's flow (see
* OrderStatusFlow::nextOptions()) — re-confirmed with the user: a
* captured payment, manual or via Stripe's webhook, should never leave an
* order sitting at 'awaiting_payment'. Only fires when status is still
* exactly 'awaiting_payment', so a duplicate/delayed capture event never
* regresses an order staff already advanced further. PaymentAuthorized
* only marks paid — an authorization is not yet captured funds, so
* status stays put until the actual capture.
*
* A refund still moves `status` (returned -> refunded/partially_refunded)
* — refunds are a normal step in Modules\Core\Order\Services\
* OrderStatusFlow's own sequence, unlike captures. Derives
* Refunded/PartialRefund from Modules\Core\Order\Support\OrderStatus::
* payment() — the existing, unchanged derived-enum logic, reused rather
* than reimplemented.
*
* Reads $event->context['order_id'] to find which Order this outcome
* belongs to — Payment has no concept of an Order, so this is the one
* place that context key gets consumed on the Order side (Payment's own
* StripePaymentDriver reads $context['order_id'] independently, for its
* own unrelated correlation need — see that class's rememberIntent()).
* belongs to — Payment has no concept of an Order.
*
* Loads and saves the model (not a bulk ::whereKey()->update()) so
* Order::observe()'s updated() hook fires and OrderStatusUpdated goes out
* the same as any other status write — see that event's own docblock for
* why it's meant to fire "regardless of what wrote it."
* Dispatches Checkout\Events\OrderPlaced itself, once placed_at is set.
* Never fires from the PaymentRefunded path — a refund can only ever
* happen after an order was already placed.
*
* Dispatches Checkout\Events\OrderPlaced itself, once placed_at is set —
* see that event's own docblock for why this, not CheckoutService, is now
* the dispatch point.
* Deliberately does NOT react to PaymentVoided.
*/
class ApplyResolvedPaymentStatus
{
public function handle(PaymentCaptured|PaymentAuthorized $event): void
public function __construct(
private readonly OrderStatusWriter $writer,
private readonly OrderStatusFlow $flow,
) {}
public function handle(PaymentCaptured|PaymentAuthorized|PaymentRefunded $event): void
{
$orderId = $event->context['order_id'] ?? null;
@@ -42,22 +61,59 @@ class ApplyResolvedPaymentStatus
$order = Order::findOrFail($orderId);
$configKey = $event instanceof PaymentCaptured ? 'captured_status' : 'authorized_status';
$status = config("lunar.payments.types.{$event->type}.{$configKey}");
if ($event instanceof PaymentRefunded) {
$this->applyRefund($order, $event);
if ($status === null) {
return;
}
$wasPlaced = ! blank($order->placed_at);
$order->update([
'status' => $status,
'placed_at' => $order->placed_at ?? now(),
]);
$this->writer->markPaid($order, $event::class);
if ($event instanceof PaymentCaptured) {
$this->advancePastAwaitingPayment($order, $event);
}
if (! $wasPlaced) {
$order->update(['placed_at' => $order->placed_at ?? now()]);
Event::dispatch(new OrderPlaced($order));
}
}
private function advancePastAwaitingPayment(Order $order, PaymentCaptured $event): void
{
if ($order->status !== 'awaiting_payment') {
return;
}
$next = $this->flow->nextOptions($order);
$target = array_key_first($next);
if ($target !== null) {
$this->writer->write($order, $target, $event::class);
}
}
/**
* Requires the refund Transaction row to already exist (Modules\Core\
* Order\Listeners\RecordPaymentTransaction must run first — see
* OrderServiceProvider's listener registration order for
* PaymentRefunded), so the relation is refreshed here rather than
* trusted from a possibly-stale $order instance.
*/
private function applyRefund(Order $order, PaymentRefunded $event): void
{
$order->load('transactions');
$target = match (OrderStatus::payment($order)) {
PaymentStatus::Refunded => 'refunded',
PaymentStatus::PartialRefund => 'partially_refunded',
default => null,
};
if ($target !== null && $order->status !== $target) {
$this->writer->write($order, $target, $event::class);
}
}
}
@@ -0,0 +1,39 @@
<?php
namespace Modules\Core\Order\Listeners;
use Modules\Core\Order\Events\OrderCompleted;
use Modules\Core\Order\Events\OrderPickedUp;
use Modules\Core\Order\Services\OrderStatusWriter;
/**
* The store-pickup mirror of AdvanceFulfillmentOnDelivered — reacts to
* OrderPickedUp (dispatched by Modules\Core\Order\Services\
* OrderFulfillmentService::markPickedUp() the moment staff confirm the
* customer collected the order) by moving `status` straight to
* 'completed'. No return-window step for store-pickup orders, per the
* business design — unlike the carrier branch, there is no 'delivered'
* intermediate value on this path.
*
* Guarded to only fire from 'picked_up' — a duplicate dispatch (e.g. a
* stale page re-submitting the action) is a silent no-op.
*/
class CompleteOrderOnPickedUp
{
public function __construct(
private readonly OrderStatusWriter $writer,
) {}
public function handle(OrderPickedUp $event): void
{
$order = $event->order;
if ($order->status !== 'picked_up') {
return;
}
$this->writer->write($order, 'completed', self::class);
OrderCompleted::dispatch($order);
}
}
@@ -0,0 +1,64 @@
<?php
namespace Modules\Core\Order\Listeners;
use Illuminate\Support\Facades\DB;
use Lunar\Models\Product;
use Lunar\Models\ProductVariant;
use Modules\Core\Checkout\Events\OrderPlaced;
/**
* The only place ProductVariant::stock is written as a result of an order —
* fires once per order regardless of capture_mode/driver, same reasoning as
* Modules\Core\Order\Notifications\OrderPlacedNotification: OrderPlaced is
* dispatched exactly once, from the one place an order's placed_at
* actually gets set (Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus),
* so this can't double-decrement across a capture/authorize/refund sequence
* the way listening to PaymentCaptured directly could.
*
* Only decrements for `purchasable === 'in_stock'` variants — 'always' and
* 'backorder' variants are deliberately allowed to sell past (or without
* regard to) their stock count already (see ProductVariant::
* canBeFulfilledAtQuantity()), so decrementing their stock would just make
* that column an inaccurate, decreasingly-negative number with no purchasing
* consequence. Only `OrderLine::type === 'physical'` lines are considered —
* a digital line has no stock to decrement (ProductVariant::getType()).
*
* A single UPDATE per variant (`DB::table(...)->decrement()`), not a
* read-then-write on the Eloquent model — avoids a lost-update race between
* two orders decrementing the same variant concurrently, and skips
* Modules\Core\Catalog\Services\ProductIndexer::stock's staleness gap for
* the DB value itself even though the search index still only refreshes on
* the next reindex event/nightly job (see that class's own docblock).
*
* Never lets stock go negative (`GREATEST(stock - qty, 0)` via a raw
* expression) — an order can still be placed against a variant whose stock
* was already fully consumed by another concurrent order (Lunar has no
* stock-reservation step at cart/checkout time), so this is a best-effort
* count, not a hard inventory guarantee.
*/
class DecrementStockOnOrderPlaced
{
public function handle(OrderPlaced $event): void
{
$lines = $event->order->lines()
->where('type', 'physical')
->where('purchasable_type', ProductVariant::morphName())
->get(['purchasable_id', 'quantity']);
foreach ($lines as $line) {
DB::table((new ProductVariant())->getTable())
->where('id', $line->purchasable_id)
->where('purchasable', 'in_stock')
->update([
'stock' => DB::raw('GREATEST(stock - '.(int) $line->quantity.', 0)'),
]);
}
$productIds = ProductVariant::whereIn('id', $lines->pluck('purchasable_id'))
->pluck('product_id')
->unique();
Product::whereIn('id', $productIds)->get()->each->searchable();
}
}
@@ -0,0 +1,35 @@
<?php
namespace Modules\Core\Order\Listeners;
use Modules\Core\Order\Services\OrderStatusWriter;
use Modules\Core\Shipping\Enums\TrackingStatus;
use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier;
/**
* Wires TrackingStatus::Failed to the 'delivery_failed' status for the
* first time — previously an unused enum case. Guarded to only fire from
* 'dispatched': a stale/duplicate checkpoint, or an order a manual action
* already moved past, is a silent no-op.
*/
class MarkDeliveryFailedOnCarrierCheckpoint
{
public function __construct(
private readonly OrderStatusWriter $writer,
) {}
public function handle(ShipmentStatusUpdatedByCarrier $event): void
{
if ($event->shipmentInfo->status !== TrackingStatus::Failed) {
return;
}
$order = $event->shipmentInfo->shipment->order;
if (! $order || $order->status !== 'dispatched') {
return;
}
$this->writer->write($order, 'delivery_failed', self::class);
}
}
@@ -0,0 +1,33 @@
<?php
namespace Modules\Core\Order\Listeners;
use Modules\Core\Order\Events\OrderPaidChanged;
use Modules\Core\Order\Events\OrderStatusChanged;
use Modules\Core\Order\Services\OrderStatusTransitionRecorder;
/**
* The one place order_status_transitions rows actually get written —
* listens to OrderStatusChanged (every write of the single `status`
* column, via Modules\Core\Order\Services\OrderStatusWriter::write()) and
* OrderPaidChanged (every write of Order::paid, via
* OrderStatusWriter::markPaid()). paid isn't really a "status", but gets
* one consistent audit trail entry ('paid', with a null from_status)
* rather than a second, separate table.
*/
class RecordStatusTransition
{
public function __construct(
private readonly OrderStatusTransitionRecorder $recorder,
) {}
public function handleStatusChanged(OrderStatusChanged $event): void
{
$this->recorder->record($event->order, $event->previousStatus, $event->newStatus, $event->causeClass);
}
public function handlePaidChanged(OrderPaidChanged $event): void
{
$this->recorder->record($event->order, null, 'paid', $event->causeClass);
}
}
@@ -0,0 +1,29 @@
<?php
namespace Modules\Core\Order\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Lunar\Models\Order;
/**
* One append-only row per write to Order::status (plus one synthetic
* 'paid' entry per Order::paid write — see
* Modules\Core\Order\Listeners\RecordStatusTransition) — see
* database/migrations/2026_09_11_000002_create_order_status_transitions_table.php
* and Modules\Core\Order\Services\OrderStatusTransitionRecorder, which is
* the only thing that ever creates a row. Never updated after creation —
* $timestamps is disabled since there's no updated_at column and
* created_at is DB-defaulted (`useCurrent()`), not Eloquent-managed.
*/
class OrderStatusTransition extends Model
{
public $timestamps = false;
protected $guarded = [];
public function order(): BelongsTo
{
return $this->belongsTo(Order::class);
}
}
@@ -0,0 +1,49 @@
<?php
namespace Modules\Core\Order\Notifications;
use Illuminate\Notifications\AnonymousNotifiable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Facades\Notification as NotificationFacade;
use Modules\Core\Notification\BaseNotification;
use Modules\Core\Order\Events\OrderCompleted;
class OrderCompletedNotification extends BaseNotification
{
public function __construct(private readonly OrderCompleted $event) {}
public static function getKey(): string
{
return 'order.completed.customer.mail';
}
public static function listensTo(): string
{
return OrderCompleted::class;
}
public function via(object $notifiable): array
{
return ['mail'];
}
public function notifiable(): AnonymousNotifiable
{
$order = $this->event->order;
$email = $order->billingAddress?->contact_email ?? $order->shippingAddress?->contact_email;
return NotificationFacade::route('mail', $email);
}
public function toMail(object $notifiable): MailMessage
{
$order = $this->event->order;
return (new MailMessage)
->subject(__('Your order :reference is complete', ['reference' => $order->reference]))
->view('core::order.notifications.completed', [
'reference' => $order->reference,
]);
}
}
@@ -0,0 +1,54 @@
<?php
namespace Modules\Core\Order\Notifications;
use Illuminate\Notifications\AnonymousNotifiable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Facades\Notification as NotificationFacade;
use Modules\Core\Notification\BaseNotification;
use Modules\Core\Order\Events\OrderDispatched;
/**
* Fills a real, previously-unfilled customer-communication gap — before
* this redesign nothing notified a customer when their carrier order left
* the building at all.
*/
class OrderDispatchedNotification extends BaseNotification
{
public function __construct(private readonly OrderDispatched $event) {}
public static function getKey(): string
{
return 'order.dispatched.customer.mail';
}
public static function listensTo(): string
{
return OrderDispatched::class;
}
public function via(object $notifiable): array
{
return ['mail'];
}
public function notifiable(): AnonymousNotifiable
{
$order = $this->event->order;
$email = $order->billingAddress?->contact_email ?? $order->shippingAddress?->contact_email;
return NotificationFacade::route('mail', $email);
}
public function toMail(object $notifiable): MailMessage
{
$order = $this->event->order;
return (new MailMessage)
->subject(__('Your order :reference is on its way', ['reference' => $order->reference]))
->view('core::order.notifications.dispatched', [
'reference' => $order->reference,
]);
}
}
@@ -0,0 +1,59 @@
<?php
namespace Modules\Core\Order\Notifications;
use Illuminate\Notifications\AnonymousNotifiable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Facades\Notification as NotificationFacade;
use Modules\Core\Notification\BaseNotification;
use Modules\Core\Order\Events\OrderReadyForPickup;
/**
* "Your order is ready to collect" — listens to the specific
* OrderReadyForPickup event (dispatched by Modules\Core\Shipping\
* Extensions\OrderViewExtension's "Mark Ready" action, store-pickup
* branch only), not the generic OrderStatusUpdated. Modules\Core\Order\
* Notifications\OrderStatusUpdatedNotification still separately
* suppresses itself for the legacy 'ready-for-pickup' status string, kept
* defensively even though nothing writes that literal value to
* Order::status anymore after this redesign.
*/
class OrderPickupReadyNotification extends BaseNotification
{
public function __construct(private readonly OrderReadyForPickup $event) {}
public static function getKey(): string
{
return 'order.pickup_ready.customer.mail';
}
public static function listensTo(): string
{
return OrderReadyForPickup::class;
}
public function via(object $notifiable): array
{
return ['mail'];
}
public function notifiable(): AnonymousNotifiable
{
$order = $this->event->order;
$email = $order->billingAddress?->contact_email ?? $order->shippingAddress?->contact_email;
return NotificationFacade::route('mail', $email);
}
public function toMail(object $notifiable): MailMessage
{
$order = $this->event->order;
return (new MailMessage)
->subject(__('Your order :reference is ready for pickup', ['reference' => $order->reference]))
->view('core::order.notifications.pickup-ready', [
'reference' => $order->reference,
]);
}
}
@@ -0,0 +1,62 @@
<?php
namespace Modules\Core\Order\Notifications;
use Illuminate\Notifications\AnonymousNotifiable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Facades\Notification as NotificationFacade;
use Modules\Core\Checkout\Events\OrderPlaced;
use Modules\Core\Notification\BaseNotification;
/**
* The order confirmation email — fires once, for every capture_mode and
* driver alike (Stripe, offline, bank-transfer), since OrderPlaced is
* dispatched from the one place an order's placed_at actually gets set
* (Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus), not from a
* driver-specific event like OrderCaptured. Before this existed, an
* offline/bank-transfer order got no placement email at all — only a
* Stripe (auto-captured) order did, via OrderCapturedNotification, which is
* a different concern (payment confirmation, not order confirmation) that
* happens to fire at the same moment for that one driver.
*/
class OrderPlacedNotification extends BaseNotification
{
public function __construct(private readonly OrderPlaced $event) {}
public static function getKey(): string
{
return 'order.placed.customer.mail';
}
public static function listensTo(): string
{
return OrderPlaced::class;
}
public function via(object $notifiable): array
{
return ['mail'];
}
public function notifiable(): AnonymousNotifiable
{
$order = $this->event->order;
$email = $order->billingAddress?->contact_email ?? $order->shippingAddress?->contact_email;
return NotificationFacade::route('mail', $email);
}
public function toMail(object $notifiable): MailMessage
{
$order = $this->event->order;
return (new MailMessage)
->subject(__('Your order :reference is confirmed', ['reference' => $order->reference]))
->view('core::order.notifications.placed', [
'reference' => $order->reference,
'total' => $order->total->formatted,
'lines' => $order->lines,
]);
}
}
@@ -22,8 +22,20 @@ class OrderStatusUpdatedNotification extends BaseNotification
return OrderStatusUpdated::class;
}
/**
* 'ready-for-pickup' has its own, richer notification
* (Modules\Core\Order\Notifications\OrderPickupReadyNotification) —
* both listen to the same OrderStatusUpdated event via
* NotificationRegistry, so without this the customer would get two
* emails for that one transition. Returning no channels is the
* standard Laravel way to suppress a notification outright.
*/
public function via(object $notifiable): array
{
if ($this->event->newStatus === 'ready-for-pickup') {
return [];
}
return ['mail'];
}
+22 -8
View File
@@ -5,18 +5,32 @@ namespace Modules\Core\Order\Observers;
use Lunar\Models\Order;
use Modules\Core\Order\Events\OrderStatusUpdated;
/**
* Generically dispatches OrderStatusUpdated for ANY write to `status`,
* regardless of what wrote it (Modules\Core\Order\Services\
* OrderStatusWriter, artisan tinker, a future API) — the general-purpose
* hook notifications listen to. OrderStatusWriter separately dispatches
* its own OrderStatusChanged (carrying $causeClass, which this event does
* not) for the audit trail — see Modules\Core\Order\Listeners\
* RecordStatusTransition.
*
* Does NOT try to generically watch Order::paid — an earlier design had
* this observer thread a "what caused this" value through a runtime
* $order->statusTransitionCause property, abandoned because
* Lunar\Models\Order's $guarded = [] means Eloquent tries to persist any
* property set that way as a real column. OrderStatusWriter::markPaid()
* dispatches OrderPaidChanged directly instead.
*/
class OrderObserver
{
public function updated(Order $order): void
{
if (! $order->wasChanged('status')) {
return;
if ($order->wasChanged('status')) {
OrderStatusUpdated::dispatch(
$order,
$order->getOriginal('status'),
$order->status,
);
}
OrderStatusUpdated::dispatch(
$order,
$order->getOriginal('status'),
$order->status,
);
}
}
@@ -0,0 +1,169 @@
<?php
namespace Modules\Core\Order\Services;
use Lunar\Models\Order;
use Lunar\Shipping\Models\ShippingMethod;
use Modules\Core\Order\DTOs\OrderFulfillmentResult;
use Modules\Core\Order\Events\OrderPickedUp;
use Modules\Core\Order\Events\OrderReadyForDispatch;
use Modules\Core\Order\Events\OrderReadyForPickup;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\DTOs\ShipmentRequest;
use Throwable;
/**
* The staff-facing fulfillment/return/payment workflow behind the three
* header actions in Modules\Core\Shipping\Extensions\OrderViewExtension
* ("Create Shipment", "Update Status", "Mark Paid") — every guard check,
* status write (via Modules\Core\Order\Services\OrderStatusWriter), and
* event dispatch lives here, keeping this workflow usable and testable
* independent of Filament.
*
* Every method re-validates its own precondition internally (not just
* trusted from the caller's own visible()-equivalent check) — protects
* against a stale page load racing a concurrent automatic transition
* (e.g. a carrier tracking checkpoint advancing the same order between
* page load and button click).
*/
class OrderFulfillmentService
{
public function __construct(
private readonly OrderStatusWriter $writer,
private readonly OrderStatusFlow $flow,
) {}
public function markReady(Order $order): OrderFulfillmentResult
{
if ($order->status !== 'processing') {
return OrderFulfillmentResult::failure('This order must be in Processing before it can be marked ready.');
}
$target = $order->isStorePickupOrder() ? 'ready_for_pickup' : 'ready_for_dispatch';
$this->writer->write($order, $target, self::class.'::markReady');
if ($order->isStorePickupOrder()) {
OrderReadyForPickup::dispatch($order);
} else {
OrderReadyForDispatch::dispatch($order);
}
return OrderFulfillmentResult::success('Order marked ready.');
}
public function createShipmentAndDispatch(Order $order, ShipmentRequest $request): OrderFulfillmentResult
{
if ($order->status !== 'ready_for_dispatch') {
return OrderFulfillmentResult::failure('This order is not ready to be dispatched.');
}
$service = $this->resolveFulfillmentService($order);
if (! $service) {
return OrderFulfillmentResult::failure('No carrier fulfillment integration is configured for this order.');
}
try {
$service->createShipment($order, $request);
} catch (Throwable $e) {
report($e);
return OrderFulfillmentResult::failure('Failed to create shipment: '.$e->getMessage());
}
$this->writer->write($order, 'dispatched', self::class.'::createShipmentAndDispatch');
return OrderFulfillmentResult::success('Shipment created and order dispatched.');
}
public function markPickedUp(Order $order): OrderFulfillmentResult
{
if ($order->status !== 'ready_for_pickup') {
return OrderFulfillmentResult::failure('This order is not ready for pickup.');
}
$this->writer->write($order, 'picked_up', self::class.'::markPickedUp');
OrderPickedUp::dispatch($order);
return OrderFulfillmentResult::success('Order marked as picked up.');
}
/**
* The general-purpose entry point for any transition with no special
* side effect — a manual override, not restricted to the guided next
* step(s), so staff can revert to an earlier status in the order's
* own branch. Validates $to is actually a member of
* OrderStatusFlow::allOptions() before writing (server-side
* re-validation of whatever the Select offered) — still refuses a
* status from the WRONG branch or an unknown value.
*/
public function transitionTo(Order $order, string $to): OrderFulfillmentResult
{
if (! array_key_exists($to, $this->flow->allOptions($order))) {
return OrderFulfillmentResult::failure('That status is not valid for this order.');
}
$this->writer->write($order, $to, self::class.'::transitionTo');
return OrderFulfillmentResult::success('Order status updated.');
}
/**
* Independent of `status` entirely — offered by the single "Update
* Status" action regardless of current status (see
* OrderStatusFlow::canMarkPaid()).
*/
public function markPaid(Order $order): OrderFulfillmentResult
{
if (! $this->flow->canMarkPaid($order)) {
return OrderFulfillmentResult::failure('This order cannot be marked paid right now.');
}
$this->writer->markPaid($order, self::class.'::markPaid');
return OrderFulfillmentResult::success('Order marked as paid.');
}
public function canCreateShipment(Order $order): bool
{
return $order->status === 'ready_for_dispatch'
&& ! $order->isStorePickupOrder()
&& $order->shipments()->exists() === false
&& $this->resolveFulfillmentService($order) !== null;
}
/**
* Public wrapper around resolveCarrier() — Modules\Core\Shipping\
* Extensions\OrderViewExtension needs to know which carrier an order
* uses to branch the "Create Shipment" form (Box Now's box-size
* repeater vs. every other carrier's plain weight field).
*/
public function carrierFor(Order $order): ?string
{
return $this->resolveCarrier($order);
}
private function resolveCarrier(Order $order): ?string
{
$code = $order->shippingAddress?->shipping_option;
if (! $code) {
return null;
}
return ShippingMethod::where('code', $code)->value('driver');
}
private function resolveFulfillmentService(Order $order): ?CarrierFulfillmentInterface
{
$carrier = $this->resolveCarrier($order);
if (! $carrier) {
return null;
}
return app(CarrierFulfillmentInterface::class, ['carrier' => $carrier]);
}
}
+140
View File
@@ -0,0 +1,140 @@
<?php
namespace Modules\Core\Order\Services;
use Lunar\Models\Order;
use Modules\Core\Payment\Models\PaymentMethod;
/**
* Two status sequences — carrier, pickup (Order::isStorePickupOrder()) —
* NOT four. Payment method (prepaid vs. cash-on-delivery) does not affect
* the status SEQUENCE at all; it only affects Order::paid, an entirely
* separate field this class also offers a transition for (see
* canMarkPaid()). `status` never includes a "paid" step — COD
* reconciliation can happen at any point in, or after, the fulfillment
* journey (same-day to months later), so it cannot occupy a fixed slot in
* a linear sequence.
*/
class OrderStatusFlow
{
private const FLOW_CARRIER = [
'awaiting_payment', 'processing', 'ready_for_dispatch', 'dispatched',
'delivered', 'completed', 'return_requested', 'returned',
];
private const FLOW_PICKUP = [
'awaiting_payment', 'processing', 'ready_for_pickup', 'picked_up',
'completed', 'return_requested', 'returned',
];
private const REFUND_OPTIONS = ['partially_refunded', 'refunded'];
private const RETURN_ELIGIBLE_FROM = ['delivered', 'picked_up', 'completed'];
public function resolveFlow(Order $order): array
{
return $order->isStorePickupOrder() ? self::FLOW_PICKUP : self::FLOW_CARRIER;
}
/**
* Order.meta['payment_method'] (written by
* Modules\Core\Checkout\Services\CheckoutService::initiatePayment())
* is the durable source of truth. Falls back to the most recent
* Transaction.driver (a payment TYPE slug) only if meta is missing —
* e.g. an order placed before this field existed.
*/
public function isCod(Order $order): bool
{
$type = $order->meta['payment_method'] ?? $order->transactions()->latest('id')->value('driver');
if ($type === null) {
return false;
}
return PaymentMethod::where('type', $type)->value('driver') === 'cash-on-delivery';
}
/**
* @return array<string, string> value => label — every status in the
* order's own branch (carrier or pickup), plus the refund options,
* for a manual-override "New status" select. Deliberately not
* filtered to nextOptions()'s guided next-step(s) — staff can jump
* to any status in their branch, including reverting to an earlier
* one (e.g. undoing a mistaken click). transitionTo() still
* validates $to is actually a member of this set server-side.
*/
public function allOptions(Order $order): array
{
$statuses = [...$this->resolveFlow($order), ...self::REFUND_OPTIONS, 'delivery_failed'];
return collect($statuses)
->unique()
->mapWithKeys(fn (string $status) => [$status => $this->label($status)])
->all();
}
/**
* @return array<string, string> value => label — status-sequence
* transitions offered as the guided next step(s). Does not include
* the "mark paid" pseudo-option — see canMarkPaid().
*/
public function nextOptions(Order $order): array
{
$flow = $this->resolveFlow($order);
$current = $order->status;
$position = array_search($current, $flow, true);
$options = [];
if ($position !== false && isset($flow[$position + 1])) {
$options[] = $flow[$position + 1];
}
// delivery_failed — a possible outcome of any delivery attempt,
// carrier flow only, checked on $current directly (not on the
// flow's literal next value) since it's a branch on the attempt
// itself, not on sequence position.
if ($current === 'dispatched') {
$options[] = 'delivery_failed';
}
// From delivery_failed: retry dispatch, or give up and treat as
// a return.
if ($current === 'delivery_failed') {
array_push($options, 'dispatched', 'return_requested');
}
if (in_array($current, self::RETURN_ELIGIBLE_FROM, true)) {
$options[] = 'return_requested';
}
if ($current === 'return_requested') {
$options[] = 'returned';
}
if ($current === 'returned') {
array_push($options, ...self::REFUND_OPTIONS);
}
return collect($options)
->unique()
->mapWithKeys(fn (string $status) => [$status => $this->label($status)])
->all();
}
/**
* Whether the "mark paid" option should be offered right now —
* entirely independent of $order->status. True whenever this is a
* cash-on-delivery order and payment hasn't been recorded yet,
* regardless of fulfillment progress (before OR after completed).
*/
public function canMarkPaid(Order $order): bool
{
return ! $order->paid && $this->isCod($order);
}
private function label(string $status): string
{
return (string) str($status)->replace('_', ' ')->title();
}
}
@@ -0,0 +1,27 @@
<?php
namespace Modules\Core\Order\Services;
use Lunar\Models\Order;
use Modules\Core\Order\Models\OrderStatusTransition;
/**
* The single place every order_status_transitions row gets written —
* called by Modules\Core\Order\Listeners\RecordStatusTransition, itself
* listening to Modules\Core\Order\Events\OrderStatusChanged and
* OrderPaidChanged, dispatched by Modules\Core\Order\Services\
* OrderStatusWriter (the only writer of Order::status/paid left in this
* package).
*/
final class OrderStatusTransitionRecorder
{
public function record(Order $order, ?string $from, string $to, string $eventClass): void
{
OrderStatusTransition::create([
'order_id' => $order->id,
'from_status' => $from,
'to_status' => $to,
'event_class' => $eventClass,
]);
}
}
+55
View File
@@ -0,0 +1,55 @@
<?php
namespace Modules\Core\Order\Services;
use Lunar\Models\Order;
use Modules\Core\Order\Events\OrderPaidChanged;
use Modules\Core\Order\Events\OrderStatusChanged;
/**
* The one place Order::status/paid actually get written — replaces the
* earlier per-axis Modules\Core\Order\Services\OrderAxisWriter now that
* there is a single status column plus one independent `paid` field (see
* Modules\Core\Order\Services\OrderStatusFlow's own docblock for why
* payment timing is not a status-sequence step).
*
* write() relies on Modules\Core\Order\Observers\OrderObserver to
* generically dispatch OrderStatusUpdated whenever `status` actually
* changes — there's no separate axis-changed event to dispatch here
* anymore, since there's only one column left to watch. markPaid() is
* genuinely independent: it dispatches its own OrderPaidChanged, since
* OrderObserver only watches `status`, not `paid`.
*
* Cause is passed explicitly through every call rather than smuggled
* through a runtime property on the model — Lunar\Models\Order has
* $guarded = [], so Eloquent treats ANY property assignment as a real
* column to persist; an earlier design that tried
* $order->statusTransitionCause = ... broke immediately with an
* "undefined column" error the moment ->update() ran.
*/
class OrderStatusWriter
{
public function write(Order $order, string $to, string $causeClass): void
{
$from = $order->status;
if ($from === $to) {
return;
}
$order->update(['status' => $to]);
OrderStatusChanged::dispatch($order, $from, $to, $causeClass);
}
public function markPaid(Order $order, string $causeClass): void
{
if ($order->paid) {
return;
}
$order->update(['paid' => true, 'paid_at' => now()]);
OrderPaidChanged::dispatch($order, $causeClass);
}
}
@@ -49,6 +49,8 @@ class TransactionRecorder
'reference' => $result->reference,
'status' => $result->status->name,
'notes' => $result->failureReason,
'card_type' => $result->meta['card_type'] ?? null,
'last_four' => $result->meta['last_four'] ?? null,
'meta' => $result->meta,
]);
}
@@ -0,0 +1,74 @@
<?php
namespace Modules\Core\Payment\Drivers;
use Illuminate\Support\Str;
use Lunar\DataTypes\Price;
use Modules\Core\Payment\Contracts\Configurable;
use Modules\Core\Payment\Contracts\SupportsPay;
use Modules\Core\Payment\Contracts\SupportsRefunds;
use Modules\Core\Payment\DTOs\PaymentResult;
use Modules\Core\Payment\Enums\PaymentResultStatus;
use Modules\Core\Payment\Events\PaymentCaptured;
use Modules\Core\Payment\Events\PaymentRefunded;
/**
* Manual/attested, same trust model as OfflinePaymentDriver — there is no
* bank API to call, so both pay() and refund() decide success immediately
* on a staff member's say-so (they've already sent/received the wire
* outside the system). Distinct from OfflinePaymentDriver in intent: this
* exists so a payment taken through a DIFFERENT method (e.g.
* cash-on-delivery) can still be REFUNDED via bank transfer — an admin
* chooses this driver explicitly in the refund action, independent of
* which driver the original payment went through (see
* Payment\Support\TransactionDriverAdapter::refundVia() and
* Order\Filament\Extensions\OrderActionsExtension). pay() exists so
* the same driver also covers receiving a payment by bank transfer, but
* the admin UI for that (bank reference, notes, proof-of-transfer upload)
* is deliberately not built yet — see the follow-up work tracked from this
* session; pay() itself is complete and usable via the registry today.
*
* $reference is generated here for the same reason as OfflinePaymentDriver's
* pay(): there is no gateway to hand one back. 'notes' in $context (not
* $data — refund() has no $data parameter) is folded into
* PaymentResult::$meta, which Order\Services\TransactionRecorder::record()
* already writes straight into Transaction.meta with no extra plumbing.
*/
class BankTransferPaymentDriver implements Configurable, SupportsPay, SupportsRefunds
{
/**
* Always true — no external dependency to be missing.
*/
public function isConfigured(): bool
{
return true;
}
public function pay(string $type, Price $amount, array $data = [], array $context = []): PaymentResult
{
$result = new PaymentResult(
status: PaymentResultStatus::Succeeded,
reference: 'bank-transfer-'.Str::uuid(),
amount: $amount,
meta: array_filter(['notes' => $data['notes'] ?? null]),
);
PaymentCaptured::dispatch($type, $result, $context);
return $result;
}
public function refund(string $reference, Price $amount, array $context = []): PaymentResult
{
$result = new PaymentResult(
status: PaymentResultStatus::Succeeded,
reference: 'bank-transfer-'.Str::uuid(),
amount: $amount,
meta: array_filter(['notes' => $context['notes'] ?? null]),
);
PaymentRefunded::dispatch('bank-transfer', $result, $context);
return $result;
}
}
@@ -0,0 +1,50 @@
<?php
namespace Modules\Core\Payment\Drivers;
use Illuminate\Support\Str;
use Lunar\DataTypes\Price;
use Modules\Core\Payment\Contracts\Configurable;
use Modules\Core\Payment\Contracts\SupportsPay;
use Modules\Core\Payment\DTOs\PaymentResult;
use Modules\Core\Payment\Enums\PaymentResultStatus;
/**
* Cash-on-delivery/cash-on-pickup — the shopper pays staff in person, at
* delivery or pickup, not at checkout, and reconciliation can happen
* anywhere from same-day to months later, entirely independent of the
* order's fulfillment progress (this is WHY Order::paid is its own field,
* not a status-sequence step — see Modules\Core\Order\Services\
* OrderStatusFlow's own docblock).
*
* Unlike OfflinePaymentDriver (cash-in-hand, immediate capture), pay()
* here must NOT dispatch PaymentCaptured — doing so would immediately
* flip Order::paid via Modules\Core\Order\Listeners\
* ApplyResolvedPaymentStatus, which is exactly wrong: no money has
* changed hands yet. Returns PaymentResultStatus::Pending instead — the
* documented convention for "unresolved" (see SupportsPay's own
* docblock). ApplyResolvedPaymentStatus and RecordPaymentTransaction both
* only listen to Captured/Authorized/Voided/Refunded, so a Pending result
* triggers neither.
*
* Order::paid only ever becomes true for a COD order via staff explicitly
* marking it received (Modules\Core\Order\Services\
* OrderFulfillmentService::markPaid()), offered by the single "Update
* Status" action at any time, independent of status.
*/
class CashOnDeliveryPaymentDriver implements Configurable, SupportsPay
{
public function isConfigured(): bool
{
return true;
}
public function pay(string $type, Price $amount, array $data = [], array $context = []): PaymentResult
{
return new PaymentResult(
status: PaymentResultStatus::Pending,
reference: 'cod-'.Str::uuid(),
amount: $amount,
);
}
}
+6 -3
View File
@@ -11,9 +11,12 @@ use Modules\Core\Payment\Enums\PaymentResultStatus;
use Modules\Core\Payment\Events\PaymentCaptured;
/**
* Shared by every payment type with no real gateway to confirm against —
* cash-in-hand, cash-on-delivery — where the shopper pays at pickup/on
* delivery, not at checkout. There is no separate hold-then-settle model
* Cash-in-hand — a shopper paying in person at the moment of pickup, with
* nothing left to reconcile afterward, so capture is immediate. NOT used
* for cash-on-delivery, which has its own Modules\Core\Payment\Drivers\
* CashOnDeliveryPaymentDriver — COD payment happens at an unpredictable
* later time (same-day to months), so it must not capture immediately the
* way this driver does. There is no separate hold-then-settle model
* (SupportsAuthorization/SupportsCaptures/SupportsVoids) and no async
* resolution (HandlesPaymentCallback) — pay() decides success immediately
* and dispatches PaymentCaptured before returning.
+83 -54
View File
@@ -4,9 +4,6 @@ namespace Modules\Core\Payment\Drivers;
use Lunar\DataTypes\Price;
use Lunar\Models\Currency;
use Lunar\Stripe\Facades\Stripe;
use Lunar\Stripe\Managers\StripeManager;
use Lunar\Stripe\Models\StripePaymentIntent;
use Modules\Core\Payment\Contracts\Configurable;
use Modules\Core\Payment\Contracts\HandlesPaymentCallback;
use Modules\Core\Payment\Contracts\SupportsAuthorization;
@@ -26,17 +23,20 @@ use Modules\Core\Payment\Events\PaymentRefundFailed;
use Modules\Core\Payment\Events\PaymentRefunded;
use Modules\Core\Payment\Events\PaymentVoidFailed;
use Modules\Core\Payment\Events\PaymentVoided;
use Modules\Core\Payment\Models\StripePaymentIntent;
use Modules\Core\Payment\Support\StripeManager;
use Stripe\Exception\ApiErrorException;
use Stripe\PaymentIntent;
/**
* Talks to Stripe's PaymentIntent API directly — deliberately NOT via
* Lunar\Stripe\Facades\Stripe::createIntent()/fetchOrCreateIntent(), which
* take a Lunar\Models\Cart and derive amount/currency from it. Payment
* must never receive a Cart (see docs/payments.md) — pay()/authorize()
* already receive $amount explicitly as their own required Lunar Price
* parameter (see PaymentResult's own docblock), the caller's job to
* assemble, same as every other driver.
* Lunar's own checkout flow (lunarphp/stripe, since removed — see
* Modules\Core\Payment\Support\StripeManager's own docblock), which took a
* Lunar\Models\Cart and derived amount/currency from it. Payment must
* never receive a Cart (see docs/payments.md) — pay()/authorize() already
* receive $amount explicitly as their own required Lunar Price parameter
* (see PaymentResult's own docblock), the caller's job to assemble, same
* as every other driver.
*
* Every amount that crosses this class's own boundary is converted right
* there: Lunar's Price -> Stripe's minor-unit int going INTO a gateway
@@ -45,12 +45,11 @@ use Stripe\PaymentIntent;
* Nothing outside this class ever sees a Stripe-scaled integer.
*
* Correlating a later handleCallback() (a separate request — a webhook)
* back to whatever $context identified this attempt is solved the same
* way lunarphp/stripe's own StripePaymentType/ProcessStripeWebhook solve
* it: real cart_id/order_id columns on Lunar\Stripe\Models\
* StripePaymentIntent (a table already owned by lunarphp/stripe, already
* shaped for exactly this), not a generic context blob. See
* docs/payments.md "Async resolution" for the full reasoning.
* back to whatever $context identified this attempt is solved via real
* cart_id/order_id columns on Modules\Core\Payment\Models\
* StripePaymentIntent (a table this app now owns outright, already shaped
* for exactly this), not a generic context blob. See docs/payments.md
* "Async resolution" for the full reasoning.
*/
class StripePaymentDriver implements
Configurable,
@@ -61,16 +60,18 @@ class StripePaymentDriver implements
SupportsRefunds,
HandlesPaymentCallback
{
public function __construct(
private readonly StripeManager $stripe,
) {}
/**
* Same key lunarphp/stripe's own StripeManager reads its API key from
* (Stripe::setApiKey(config('services.stripe.key'))) — no key, no
* usable driver.
* Same key StripeManager reads its API key from — no key, no usable
* driver.
*/
public function isConfigured(): bool
{
return filled(config('services.stripe.key'));
}
/**
* Atomic charge — capture_method: automatic. Stripe still frequently
* confirms into requires_action/requires_confirmation rather than
@@ -95,17 +96,27 @@ class StripePaymentDriver implements
private function createAndConfirm(string $type, Price $amount, array $data, array $context, string $captureMethod): PaymentResult
{
$params = [
'amount' => StripeManager::toStripeAmount($amount->value, $amount->currency),
'currency' => $amount->currency->code,
'capture_method' => $captureMethod,
'confirm' => true,
// 'never' rather than the client-side paymentMethodTypes: ['card']
// restriction alone — the storefront's Payment Element already
// excludes every redirect-based method, but without this Stripe
// still falls back to whatever's enabled in the Dashboard and
// demands a return_url on confirm. Setting this unconditionally
// (not only when no payment_method is given) matches the actual
// flow: a payment_method is always supplied here.
'automatic_payment_methods' => ['enabled' => true, 'allow_redirects' => 'never'],
];
if (isset($data['payment_method'])) {
$params['payment_method'] = $data['payment_method'];
}
try {
$paymentIntent = Stripe::getClient()->paymentIntents->create([
'amount' => StripeManager::toStripeAmount($amount->value, $amount->currency),
'currency' => $amount->currency->code,
'capture_method' => $captureMethod,
'confirm' => true,
'payment_method' => $data['payment_method'] ?? null,
'automatic_payment_methods' => isset($data['payment_method'])
? null
: ['enabled' => true],
]);
$paymentIntent = $this->stripe->getClient()->paymentIntents->create($params);
} catch (ApiErrorException $e) {
return $this->declined($type, $amount, $e, $context, authorizing: $captureMethod === 'manual');
}
@@ -119,7 +130,7 @@ class StripePaymentDriver implements
{
[$intentModel, $type, $context] = $this->resolveIntentModel($reference, $context, $data['type'] ?? '');
$paymentIntent = Stripe::getClient()->paymentIntents->retrieve($reference);
$paymentIntent = $this->stripe->getClient()->paymentIntents->retrieve($reference);
$authorizing = $paymentIntent->capture_method === PaymentIntent::CAPTURE_METHOD_MANUAL;
@@ -127,7 +138,7 @@ class StripePaymentDriver implements
// automatic capture_method, but Stripe stopped short of
// capturing (rare, but the API contract allows it) — finish
// the job pay() started.
$paymentIntent = Stripe::getClient()->paymentIntents->capture($reference);
$paymentIntent = $this->stripe->getClient()->paymentIntents->capture($reference);
}
$intentModel?->update(['status' => $paymentIntent->status]);
@@ -142,7 +153,7 @@ class StripePaymentDriver implements
[$intentModel, $type, $context] = $this->resolveIntentModel($reference, $context);
try {
$paymentIntent = Stripe::getClient()->paymentIntents->capture($reference, [
$paymentIntent = $this->stripe->getClient()->paymentIntents->capture($reference, [
'amount_to_capture' => StripeManager::toStripeAmount($amount->value, $amount->currency),
]);
} catch (ApiErrorException $e) {
@@ -161,6 +172,7 @@ class StripePaymentDriver implements
reference: $paymentIntent->id,
amount: $amount,
raw: $paymentIntent->toArray(),
meta: $this->cardMetaFromIntent($paymentIntent),
);
$paymentIntent->status === PaymentIntent::STATUS_SUCCEEDED
@@ -175,7 +187,7 @@ class StripePaymentDriver implements
[$intentModel, $type, $context] = $this->resolveIntentModel($reference, $context);
try {
$paymentIntent = Stripe::getClient()->paymentIntents->cancel($reference);
$paymentIntent = $this->stripe->getClient()->paymentIntents->cancel($reference);
} catch (ApiErrorException $e) {
$result = $this->failure($amount, $e, $reference);
PaymentVoidFailed::dispatch($type, $result, $context);
@@ -206,7 +218,7 @@ class StripePaymentDriver implements
[$intentModel, $type, $context] = $this->resolveIntentModel($reference, $context);
try {
$refund = Stripe::getClient()->refunds->create([
$refund = $this->stripe->getClient()->refunds->create([
'payment_intent' => $reference,
'amount' => StripeManager::toStripeAmount($amount->value, $amount->currency),
]);
@@ -243,7 +255,7 @@ class StripePaymentDriver implements
'order_id' => $context['order_id'] ?? null,
'status' => $paymentIntent->status,
'payment_type' => $type,
'context' => json_encode($context),
'context' => $context,
]);
}
@@ -268,28 +280,10 @@ class StripePaymentDriver implements
return [
$intentModel,
$intentModel?->payment_type ?? $typeFallback,
$this->decodeContext($intentModel) ?? $context,
$intentModel?->context ?? $context,
];
}
/**
* StripePaymentIntent is a vendor model (lunarphp/stripe) with no cast
* declared for our own 'context' column (added by boboko-core's own
* migration, see database/migrations/..._add_context_to_stripe_
* payment_intents.php) — we can't edit the vendor model to add one, so
* decode manually here instead of assuming Eloquent already did it.
*
* @return array<string, mixed>|null
*/
private function decodeContext(?StripePaymentIntent $intentModel): ?array
{
if (! $intentModel || ! $intentModel->context) {
return null;
}
return json_decode($intentModel->context, associative: true) ?: null;
}
/**
* Converts a live Stripe PaymentIntent's own amount/currency back
* into Lunar's Price — the one place this class reads a Stripe
@@ -331,6 +325,7 @@ class StripePaymentDriver implements
amount: $amount,
failureReason: $paymentIntent->last_payment_error->message ?? null,
raw: $paymentIntent->toArray(),
meta: $status === PaymentResultStatus::Pending ? [] : $this->cardMetaFromIntent($paymentIntent),
continuation: $continuation,
);
@@ -353,6 +348,40 @@ class StripePaymentDriver implements
return $result;
}
/**
* card_type/last_four for Modules\Core\Order\Services\
* TransactionRecorder to map onto Transaction (see PaymentResult::
* $meta's own docblock) — same fields, same source
* (payment_method_details on the underlying Charge) as lunarphp/
* stripe's own StoreCharges, just reached via latest_charge instead of
* an order-level charge list, since this driver has no Order/Cart to
* enumerate charges from.
*
* @return array{card_type?: string, last_four?: string}
*/
private function cardMetaFromIntent(PaymentIntent $paymentIntent): array
{
$chargeId = $paymentIntent->latest_charge;
if (blank($chargeId)) {
return [];
}
$charge = $this->stripe->getCharge(is_string($chargeId) ? $chargeId : $chargeId->id);
$paymentType = collect($charge->payment_method_details)->keys()->first();
$details = collect($charge->payment_method_details)->first();
if (blank($details)) {
return [];
}
return array_filter([
'card_type' => $details['brand'] ?? $paymentType,
'last_four' => $details['last4'] ?? null,
], fn ($value) => filled($value));
}
private function declined(string $type, Price $amount, ApiErrorException $e, array $context, bool $authorizing): PaymentResult
{
$result = $this->failure($amount, $e);
@@ -0,0 +1,12 @@
<?php
namespace Modules\Core\Payment\Events;
use Modules\Core\Payment\Models\PaymentMethod;
class PaymentMethodCreated
{
public function __construct(
public readonly PaymentMethod $method,
) {}
}
@@ -0,0 +1,15 @@
<?php
namespace Modules\Core\Payment\Events;
class PaymentMethodDeleted
{
/**
* @param array<string, mixed> $method Snapshot of the deleted row —
* already gone from the database by dispatch time, so this can't be
* a fresh PaymentMethod model instance.
*/
public function __construct(
public readonly array $method,
) {}
}
@@ -0,0 +1,17 @@
<?php
namespace Modules\Core\Payment\Events;
use Modules\Core\Payment\Models\PaymentMethod;
class PaymentMethodUpdated
{
/**
* @param array<string, mixed> $old Snapshot of the changed attributes
* before the update.
*/
public function __construct(
public readonly PaymentMethod $method,
public readonly array $old,
) {}
}
@@ -0,0 +1,17 @@
<?php
namespace Modules\Core\Payment\Events;
class PaymentMethodsReordered
{
/**
* @param array<int, int> $ids PaymentMethod ids, in their new order —
* the same array Filament's own reorderTable() already wrote to the
* database directly (bulk SQL, not PaymentMethodService::update() —
* see PaymentMethodResource's own docblock for why this is the one
* PaymentMethod write that doesn't go through the service).
*/
public function __construct(
public readonly array $ids,
) {}
}
@@ -3,21 +3,57 @@
namespace Modules\Core\Payment\Filament\Resources;
use Filament\Actions\Action;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Component;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\ToggleColumn;
use Filament\Tables\Table;
use Illuminate\Support\Facades\Event;
use Modules\Core\Payment\Contracts\Configurable;
use Modules\Core\Payment\Events\PaymentMethodsReordered;
use Modules\Core\Payment\Filament\Resources\PaymentMethodResource\Pages\ListPaymentMethods;
use Modules\Core\Payment\Models\PaymentMethod;
use Modules\Core\Payment\Services\PaymentDriverRegistry;
use Modules\Core\Payment\Services\PaymentMethodCache;
use Modules\Core\Payment\Services\PaymentMethodService;
/**
* One row per payment type key (config('lunar.payments.types')), seeded by
* InstallLunarCommand — never created/deleted here, only edited. `enabled`
* toggles inline; `data.fee` (currently the only type-specific setting, for
* cash-on-delivery's flat surcharge — see ApplyCashOnDeliveryFee) is edited
* via a modal action rather than a dedicated form field, since not every
* type has the same data keys.
* The DB-instance layer for Payment (see docs/payments.md) — admin
* creatable/deletable, same as Lunar's own ShippingMethodResource. A row's
* `driver` is picked from a Select populated by
* PaymentDriverRegistry::labels() (mirrors Modules\Core\Shipping\
* Extensions\ShippingMethodResourceExtension::driverSelect()'s use of
* Shipping::getSupportedDrivers()), not a hardcoded options list, and
* never the raw driver class name — a third-party driver registered from
* its own package's service provider shows up here with no change to
* this class.
*
* Every write goes through Modules\Core\Payment\Services\
* PaymentMethodService — create/edit/delete/the enabled toggle all call
* it, not PaymentMethod::create()/update()/delete() directly, so cache
* invalidation and event dispatch happen in one place. The ONE exception
* is drag-to-reorder: Filament's own reorderTable() always writes the new
* `position` values via its own raw bulk SQL query before our
* afterReordering() hook ever runs — there is no seam to route that
* specific write through the service (short of disabling drag-reorder
* entirely and rebuilding it from scratch), so that hook only forgets the
* cache and dispatches PaymentMethodsReordered; the data itself is
* already correct in the database by the time it fires.
*
* `driver_missing_at` (set by the `boboko:payment:sync-drivers` command
* when a row's driver no longer resolves) drives the "Driver status"
* column, deliberately distinct from `enabled` — an admin needs to tell
* "I turned this off" apart from "this driver isn't usable right now" at
* a glance, not have both look like the same disabled state. That column
* also folds in Configurable::isConfigured() (e.g. Stripe with no API key
* set) — a class-resolves-but-isn't-usable state that CheckoutService::
* getPaymentMethods() filters out identically to a missing driver, so an
* admin needs the same at-a-glance warning for it, not just a silently
* absent checkout option.
*
*/
class PaymentMethodResource extends Resource
{
@@ -35,10 +71,30 @@ class PaymentMethodResource extends Resource
{
return $table
->columns([
TextColumn::make('position')
->label('Order')
->sortable(),
TextColumn::make('name')
->label('Name')
->searchable(),
TextColumn::make('type')
->label('Type'),
TextColumn::make('driver')
->label('Driver')
->formatStateUsing(fn (?string $state) => static::driverLabel($state)),
IconColumn::make('driver_missing_at')
->label('Driver status')
->boolean()
->state(fn (PaymentMethod $record) => ! $record->driver_missing_at && static::driverIsConfigured($record->driver))
->trueIcon('heroicon-o-check-circle')
->falseIcon('heroicon-o-exclamation-triangle')
->trueColor('success')
->falseColor('danger')
->tooltip(fn (PaymentMethod $record) => static::driverStatusTooltip($record)),
ToggleColumn::make('enabled')
->label('Enabled'),
->label('Enabled')
->updateStateUsing(fn (PaymentMethod $record, $state) => app(PaymentMethodService::class)
->update($record, ['enabled' => $state])),
TextColumn::make('data.fee')
->label('Fee')
->formatStateUsing(fn (?int $state) => $state
@@ -48,10 +104,85 @@ class PaymentMethodResource extends Resource
->label('Last updated')
->dateTime(),
])
->reorderable('position')
->afterReordering(function (array $order) {
app(PaymentMethodCache::class)->forget();
Event::dispatch(new PaymentMethodsReordered(array_map('intval', array_values($order))));
})
->recordActions([
static::editAction(),
static::editFeeAction(),
static::deleteAction(),
])
->defaultSort('type');
->defaultSort('position');
}
/**
* @return array<Component>
*/
public static function getFormComponents(): array
{
return [
TextInput::make('name')
->label('Name')
->required()
->maxLength(255),
TextInput::make('type')
->label('Type')
->helperText('Machine-facing slug — stored on the cart/order, used by other code to identify this method. Cannot be changed once orders reference it.')
->required()
->unique(ignoreRecord: true)
->maxLength(255),
static::getDriverFormComponent(),
Select::make('capture_mode')
->label('Capture mode')
->helperText('Whether checkout charges immediately, or places a hold to settle later.')
->options([
'pay' => 'Charge immediately',
'authorize' => 'Hold now, charge later',
])
->default('pay')
->live()
->required(),
];
}
public static function getDriverFormComponent(): Component
{
return Select::make('driver')
->label('Driver')
->options(fn () => app(PaymentDriverRegistry::class)->labels())
->required();
}
public static function getPages(): array
{
return [
'index' => ListPaymentMethods::route('/'),
];
}
public static function canCreate(): bool
{
return true;
}
public static function canDelete($record = null): bool
{
return true;
}
private static function editAction(): Action
{
return Action::make('edit')
->label('Edit')
->icon('heroicon-o-pencil-square')
->schema(static::getFormComponents())
->fillForm(fn (PaymentMethod $record) => $record->only([
'name', 'type', 'driver', 'capture_mode',
]))
->action(fn (PaymentMethod $record, array $data) => app(PaymentMethodService::class)->update($record, $data));
}
/**
@@ -76,7 +207,7 @@ class PaymentMethodResource extends Resource
'fee' => filled($record->data['fee'] ?? null) ? $record->data['fee'] / 100 : null,
])
->action(function (PaymentMethod $record, array $data) {
$record->update([
app(PaymentMethodService::class)->update($record, [
'data' => [
...$record->data->toArray(),
'fee' => filled($data['fee']) ? (int) round($data['fee'] * 100) : null,
@@ -85,20 +216,51 @@ class PaymentMethodResource extends Resource
});
}
public static function getPages(): array
private static function deleteAction(): Action
{
return [
'index' => ListPaymentMethods::route('/'),
];
return Action::make('delete')
->label('Delete')
->icon('heroicon-o-trash')
->color('danger')
->requiresConfirmation()
->action(fn (PaymentMethod $record) => app(PaymentMethodService::class)->delete($record));
}
public static function canCreate(): bool
private static function driverLabel(?string $key): string
{
return false;
if ($key === null) {
return '—';
}
return app(PaymentDriverRegistry::class)->label($key) ?? $key;
}
public static function canDelete($record = null): bool
/**
* False for a missing driver too, since Configurable::isConfigured()
* has nothing to ask in that case — driverStatusTooltip() below is
* what tells the two reasons apart for the admin.
*/
private static function driverIsConfigured(?string $key): bool
{
return false;
$driver = $key ? app(PaymentDriverRegistry::class)->resolve($key) : null;
if (! $driver instanceof Configurable) {
return false;
}
return $driver->isConfigured();
}
private static function driverStatusTooltip(PaymentMethod $record): string
{
if ($record->driver_missing_at) {
return 'Driver not found as of '.$record->driver_missing_at->diffForHumans();
}
if (! static::driverIsConfigured($record->driver)) {
return 'Driver resolves, but is missing required configuration (e.g. an API key) — it will not be offered at checkout.';
}
return 'Driver resolves correctly and is fully configured.';
}
}
@@ -2,10 +2,31 @@
namespace Modules\Core\Payment\Filament\Resources\PaymentMethodResource\Pages;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
use Modules\Core\Payment\Filament\Resources\PaymentMethodResource;
use Modules\Core\Payment\Models\PaymentMethod;
use Modules\Core\Payment\Services\PaymentMethodService;
class ListPaymentMethods extends ListRecords
{
protected static string $resource = PaymentMethodResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make()
->schema(PaymentMethodResource::getFormComponents())
->fillForm(fn () => [
'position' => (PaymentMethod::max('position') ?? 0) + 1,
'enabled' => false,
'data' => [],
])
// Every PaymentMethod write goes through PaymentMethodService
// — see PaymentMethodResource's own docblock — so this
// replaces CreateAction's default $model::create($data), not
// just the form/fill behavior above.
->using(fn (array $data) => app(PaymentMethodService::class)->create($data)),
];
}
}
@@ -9,21 +9,20 @@ use Modules\Core\Payment\Drivers\StripePaymentDriver;
use Stripe\Webhook;
/**
* A boboko-owned webhook endpoint for Stripe — deliberately NOT
* lunarphp/stripe's own route (vendor/lunarphp/stripe/routes/webhooks.php),
* which dispatches into Lunar's own Payments::driver('stripe') flow (the
* flow StripePaymentDriver was built to replace, see that class's own
* docblock). Signature verification is handled by
* Lunar\Stripe\Http\Middleware\StripeWebhookMiddleware, registered on this
* route (see src/Payment/routes/webhooks.php) — pure Stripe SDK
* verification + event-type filtering, safe to reuse even though this
* controller never touches the rest of that vendor package's flow. This
* controller verifies the signature again itself (Webhook::constructEvent())
* to get the constructed Event object — the middleware doesn't stash one
* anywhere reusable, it only gates the request through.
* A boboko-owned webhook endpoint for Stripe — never went through Lunar's
* own Payments::driver('stripe') flow (the flow StripePaymentDriver was
* built to replace, see that class's own docblock), and lunarphp/stripe
* has since been removed entirely (see Modules\Core\Payment\Support\
* StripeManager's own docblock). Signature verification is handled by
* Modules\Core\Payment\Http\Middleware\StripeWebhookMiddleware, registered
* on this route (see src/Payment/routes/webhooks.php) — pure Stripe SDK
* verification + event-type filtering. This controller verifies the
* signature again itself (Webhook::constructEvent()) to get the
* constructed Event object — the middleware doesn't stash one anywhere
* reusable, it only gates the request through.
*
* Resolves the driver directly by class, not via
* Modules\Core\Payment\Services\PaymentDriverResolver — this endpoint is
* Modules\Core\Payment\Services\PaymentDriverRegistry — this endpoint is
* inherently Stripe-specific (Stripe's own webhook payload carries no
* boboko payment-type key, only its own payment_intent id), and
* StripePaymentDriver::handleCallback() already recovers $type itself
@@ -0,0 +1,51 @@
<?php
namespace Modules\Core\Payment\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Stripe\Exception\SignatureVerificationException;
use Stripe\Exception\UnexpectedValueException;
use Stripe\Webhook;
/**
* First-party replacement for Lunar\Stripe\Http\Middleware\
* StripeWebhookMiddleware (lunarphp/stripe removed — see
* Modules\Core\Payment\Support\StripeManager's own docblock). Registered
* on the same route as before (src/Payment/routes/webhooks.php) purely to
* gate malformed/irrelevant requests before they reach
* Modules\Core\Payment\Http\Controllers\StripeWebhookController, which
* re-verifies the signature itself (see that controller's own docblock)
* to get the constructed Event object — this duplication predates the
* package removal and is left unchanged here.
*/
class StripeWebhookMiddleware
{
public function handle(Request $request, ?Closure $next = null)
{
$secret = config('services.stripe.webhooks.lunar');
$stripeSig = $request->header('Stripe-Signature');
try {
$event = Webhook::constructEvent(
$request->getContent(),
$stripeSig,
$secret
);
} catch (UnexpectedValueException|SignatureVerificationException $e) {
abort(400, $e->getMessage());
}
if (! in_array(
$event->type,
[
'payment_intent.payment_failed',
'payment_intent.succeeded',
]
)) {
return response('', 200);
}
return $next($request);
}
}
@@ -0,0 +1,60 @@
<?php
namespace Modules\Core\Payment\Listeners;
use Modules\Core\Logging\ActivityLogService;
use Modules\Core\Payment\Events\PaymentMethodCreated;
use Modules\Core\Payment\Events\PaymentMethodDeleted;
use Modules\Core\Payment\Events\PaymentMethodUpdated;
use Modules\Core\Payment\Models\PaymentMethod;
/**
* Same pattern as Localization\Listeners\LogTranslationActivity — routes
* PaymentMethodService's own events through the existing
* Logging\ActivityLogService instead of PaymentMethod separately opting
* into Lunar\Base\Traits\LogsActivity (Spatie's generic model-observer
* logging): PaymentMethodUpdated::$old and PaymentMethodDeleted::$method
* already carry richer, deliberate before/after context than Eloquent's
* own dirty-attribute diffing would reconstruct on its own.
*
* PaymentMethodDeleted's snapshot is a plain array (the row is already
* gone from the database by dispatch time — see that event's own
* docblock), so performedOn() gets an unsaved PaymentMethod instance
* built from it purely to carry the right subject_type/id, not a real
* persisted model.
*
* PaymentMethodsReordered is deliberately NOT logged here — it's a
* multi-row position change (ActivityLogService's methods all take one
* Model $subject) for a low-stakes, purely-cosmetic setting, not worth
* forcing into a one-subject shape or adding a new method to the shared
* service for.
*/
class LogPaymentMethodActivity
{
public function __construct(
private readonly ActivityLogService $activityLog,
) {}
public function handleCreated(PaymentMethodCreated $event): void
{
$this->activityLog->created($event->method, $event->method->getAttributes());
}
public function handleUpdated(PaymentMethodUpdated $event): void
{
$this->activityLog->updated(
$event->method,
$event->old,
$event->method->only(array_keys($event->old)),
);
}
public function handleDeleted(PaymentMethodDeleted $event): void
{
$subject = (new PaymentMethod)->forceFill($event->method);
$subject->exists = true;
$subject->id = $event->method['id'];
$this->activityLog->deleted($subject, $event->method);
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace Modules\Core\Payment\Models;
use Lunar\Models\Transaction;
use Modules\Core\Payment\Support\TransactionDriverAdapter;
/**
* Registered via Lunar\Facades\ModelManifest::replace(Lunar\Models\
* Contracts\Transaction::class, self::class) in PaymentServiceProvider —
* the same contract-swap mechanism this codebase already uses for
* Customer/Staff. Every place Lunar's own code resolves a transaction via
* Transaction::modelClass() (which reads this replacement, see
* Lunar\Base\Traits\HasModelExtending::modelClass()) — including
* Order::transactions()'s own hasMany(Transaction::modelClass()) relation
* — gets an instance of THIS class instead of the vendor's own
* Lunar\Models\Transaction. No override anywhere else is needed: this is
* the one seam that makes $order->transactions, and everything the admin
* panel's refund/capture actions call on one of those rows, silently run
* through our own system.
*
* Only driver() is overridden — refund()/capture()/paymentChecks() on the
* parent class all just call driver()->{method}(), so replacing what
* driver() returns is the entire fix (see TransactionDriverAdapter).
*/
class CoreTransaction extends Transaction
{
public function driver(): TransactionDriverAdapter
{
return app(TransactionDriverAdapter::class);
}
}
+21 -11
View File
@@ -6,17 +6,25 @@ use Illuminate\Database\Eloquent\Casts\AsArrayObject;
use Illuminate\Database\Eloquent\Model;
/**
* Admin-editable settings for one payment type key (matching a key in
* config('lunar.payments.types')) — enabled/disabled, and whatever type-
* specific data it needs (starts with 'fee' for cash-on-delivery's flat
* surcharge). Mirrors Lunar's own Discount model: a single jsonb 'data'
* column holding keyed settings, rather than a fixed column per setting or
* a separate conditions table — new settings are a code change (a new key
* read from data), not a migration.
*
* Seeded once per type by InstallLunarCommand (skip-if-exists, same
* idempotent convention as seedStorefrontLabels()) — never auto-created on
* read, so a read path stays a pure read.
* A merchant-configured payment method — the DB-instance layer, admin
* creatable/deletable, same split Modules\Core\Shipping's own
* shipping_methods table already has (see docs/payments.md):
* - type: unique, machine-facing slug (Cart::meta['payment_method'],
* ApplyPaymentMethodFee's lookup key, every Payment event's $type).
* - name: admin-facing label.
* - driver: the Modules\Core\Payment\Services\PaymentDriverRegistry key
* — NOT the same as `type`, and not unique (two rows can share one
* driver, e.g. two differently-named offline-style methods).
* - capture_mode: 'pay' or 'authorize' — which SupportsPay/
* SupportsAuthorization method CheckoutService::initiatePayment()
* calls for this row.
* - position: admin-controlled display/checkout order.
* - driver_missing_at: set by `payment:sync-drivers` when `driver` no
* longer resolves via the registry — separate from `enabled`, so a
* driver vanishing (a deploy removed it) is never confused with an
* admin's own manual toggle.
* - data: jsonb, driver-specific settings that don't warrant their own
* column (starts with 'fee', the offline flat surcharge).
*/
class PaymentMethod extends Model
{
@@ -24,6 +32,8 @@ class PaymentMethod extends Model
protected $casts = [
'enabled' => 'boolean',
'position' => 'integer',
'driver_missing_at' => 'datetime',
'data' => AsArrayObject::class,
];
}
@@ -0,0 +1,32 @@
<?php
namespace Modules\Core\Payment\Models;
use Lunar\Base\BaseModel;
/**
* First-party replacement for Lunar\Stripe\Models\StripePaymentIntent (the
* lunarphp/stripe package was removed — see Modules\Core\Payment\Support\
* StripeManager's own docblock). Same table (lunar_stripe_payment_intents,
* created by database/migrations/..._create_stripe_payment_intents_table,
* a first-party copy of the vendor migration), including the app-owned
* `context`/`payment_type` columns Modules\Core\Payment\Drivers\
* StripePaymentDriver::handleCallback() needs to recover $context/$type
* across the separate request a webhook arrives on — see that class's own
* docblock for "Async resolution".
*
* Extends Lunar\Base\BaseModel (from lunarphp/core, unaffected by removing
* lunarphp/stripe) purely so table-prefix resolution
* (config('lunar.database.table_prefix')) stays identical to how the
* vendor model resolved it — this table was created under that prefix.
*/
class StripePaymentIntent extends BaseModel
{
protected $table = 'stripe_payment_intents';
protected $guarded = [];
protected $casts = [
'context' => 'array',
];
}
@@ -1,31 +0,0 @@
<?php
namespace Modules\Core\Payment\Pipelines\Cart;
use Closure;
use Lunar\DataTypes\Price;
use Lunar\Models\Contracts\Cart as CartContract;
use Modules\Core\Payment\Models\PaymentMethod;
final class ApplyCashOnDeliveryFee
{
/**
* Called just before cart totals are calculated.
*
* @param Closure(CartContract): mixed $next
*/
public function handle(CartContract $cart, Closure $next): mixed
{
if (($cart->meta['payment_method'] ?? null) === 'cash-on-delivery') {
$fee = (int) (PaymentMethod::where('type', 'cash-on-delivery')->value('data->fee') ?? 0);
$cart->shippingTotal = new Price(
($cart->shippingTotal?->value ?? 0) + $fee,
$cart->currency,
1
);
}
return $next($cart);
}
}
@@ -0,0 +1,49 @@
<?php
namespace Modules\Core\Payment\Pipelines\Cart;
use Closure;
use Lunar\Base\ValueObjects\Cart\ShippingBreakdownItem;
use Lunar\DataTypes\Price;
use Lunar\Models\Contracts\Cart as CartContract;
use Modules\Core\Payment\Models\PaymentMethod;
final class ApplyPaymentMethodFee
{
/**
* Called just before cart totals are calculated, right after
* Lunar\Pipelines\Cart\ApplyShipping. Generic across every
* Modules\Core\Payment\Models\PaymentMethod row, not just cash on
* delivery — whichever type the shopper picked (Cart::meta
* ['payment_method']), its own `data.fee` (set via the Filament "Edit
* fee" action) is applied if present, no matter its slug/name/driver.
*
* Must add the fee as its own Lunar\Base\ValueObjects\Cart\
* ShippingBreakdownItem on $cart->shippingBreakdown rather than
* bumping $cart->shippingTotal directly — the later Lunar\Pipelines\
* Cart\CalculateTax step unconditionally recomputes shippingTotal
* (and shipping tax) from shippingBreakdown's item sum, so a value
* set only on the plain property is silently discarded before the
* cart finishes calculating.
*
* @param Closure(CartContract): mixed $next
*/
public function handle(CartContract $cart, Closure $next): mixed
{
$type = $cart->meta['payment_method'] ?? null;
if ($type) {
$fee = (int) (PaymentMethod::where('type', $type)->first()?->data['fee'] ?? 0);
if ($fee > 0) {
$cart->shippingBreakdown->items->put('payment-method-fee', new ShippingBreakdownItem(
name: 'Payment method fee',
identifier: 'payment-method-fee',
price: new Price($fee, $cart->currency, 1),
));
}
}
return $next($cart);
}
}
@@ -0,0 +1,107 @@
<?php
namespace Modules\Core\Payment\Services;
/**
* Which payment driver CLASSES exist this deploy — the code-registry layer,
* mirroring Lunar\Shipping\Managers\ShippingManager's own built-in-methods
* + Manager::extend() pattern, but purpose-built rather than extending
* Illuminate\Support\Manager: Manager's create{X}Driver() convention fits
* a uniform one-interface-per-driver contract (ShippingRateInterface); a
* Payment driver instead implements several independent, opt-in capability
* interfaces at once (Configurable, SupportsPay, SupportsAuthorization,
* ...), so there's no single "the" method to generate per driver.
*
* Deliberately knows NOTHING about Modules\Core\Payment\Models\PaymentMethod
* or the database — resolve() is a pure "does this key still exist"
* lookup. Whether a resolved driver is administratively enabled, or
* reports itself Configurable::isConfigured(), is the DOMAIN's job
* (Modules\Core\Checkout\Services\CheckoutService::getPaymentMethods()) —
* see docs/payments.md. This split is what lets the identical registry
* shape be lifted for a future Invoicing/AntiFraud domain without dragging
* Payment-specific concepts along with it.
*
* Built-in drivers are registered in Modules\Core\Providers\
* PaymentServiceProvider::boot() via register(); a consuming app or a
* future payment-provider package registers its own the same way, from
* its own service provider's boot() — exactly how Shipping::extend() works
* for ACS/Box Now (src/Providers/ShippingServiceProvider.php).
*/
class PaymentDriverRegistry
{
/**
* @var array<string, string>
*/
private array $drivers = [];
/**
* @var array<string, string>
*/
private array $labels = [];
/**
* $key is the registry key a Modules\Core\Payment\Models\PaymentMethod
* row's own `driver` column stores — NOT the same as that row's `type`
* (its merchant-facing slug). Two rows can share one driver key (e.g.
* both 'cash-on-delivery' and 'cash-in-hand' using the same 'offline'
* driver with different type/name/fee).
*
* $label is a short, human-readable name (e.g. "Stripe", "Offline /
* Manual") — this is where that comes from, not $driverClass's own
* FQCN. Payment's driver classes implement several independent,
* opt-in capability interfaces (Configurable, SupportsPay, ...), none
* of which carries a display name the way Lunar\Shipping\Interfaces\
* ShippingRateInterface::name() does for every shipping driver — the
* registry is the one place that DOES know every driver at once, so
* it's the natural (and only) place to also hold this.
*/
public function register(string $key, string $driverClass, string $label): void
{
$this->drivers[$key] = $driverClass;
$this->labels[$key] = $label;
}
/**
* Null if $key was never registered — deliberately non-throwing, same
* reasoning the old PaymentDriverResolver already had: a caller
* checking availability (or the payment:sync-drivers command checking
* every PaymentMethod row) needs "not found" to be a normal, silent
* result, not an exception to catch.
*/
public function resolve(string $key): ?object
{
$driverClass = $this->drivers[$key] ?? null;
return $driverClass ? app($driverClass) : null;
}
/**
* Every registered key => driver class — what payment:sync-drivers
* checks every PaymentMethod row's `driver` column against.
*
* @return array<string, string>
*/
public function all(): array
{
return $this->drivers;
}
/**
* Every registered key => human-readable label — what a Filament
* driver Select populates its options from (mirroring
* ShippingMethodResourceExtension::driverSelect()'s use of
* Shipping::getSupportedDrivers(), which reads each driver's own
* name()) — never the raw class name from all().
*
* @return array<string, string>
*/
public function labels(): array
{
return $this->labels;
}
public function label(string $key): ?string
{
return $this->labels[$key] ?? null;
}
}
@@ -1,34 +0,0 @@
<?php
namespace Modules\Core\Payment\Services;
/**
* Resolves a payment type key (e.g. 'stripe', 'cash-on-delivery') to its
* registered driver instance — extracted out of CheckoutService so both it
* and anything else needing the same lookup share one implementation
* instead of duplicating this config read.
*
* Returns a plain object, not a shared interface — Payment's own drivers
* implement several independent, orthogonal capability interfaces at once
* (Configurable, SupportsPay, SupportsAuthorization, ...; see
* StripePaymentDriver implementing all six). There is no single common
* "PaymentDriver" contract to type this against; a caller checks
* `instanceof SupportsPay` / `instanceof SupportsAuthorization` itself,
* the same way Payment's own contracts are designed to be consumed.
*/
class PaymentDriverResolver
{
/**
* Null if $type has no 'payment_driver' registered in
* config('lunar.payments.types.<type>') at all — deliberately
* non-throwing so a caller like CheckoutService::getPaymentMethods()
* can filter unresolvable types silently rather than treating "not
* registered" as an error condition when just checking availability.
*/
public function resolve(string $type): ?object
{
$driverClass = config("lunar.payments.types.{$type}.payment_driver");
return $driverClass ? app($driverClass) : null;
}
}
@@ -0,0 +1,41 @@
<?php
namespace Modules\Core\Payment\Services;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Modules\Core\Payment\Models\PaymentMethod;
/**
* Cached read layer over PaymentMethod — the single source both
* Modules\Core\Checkout\Services\CheckoutService (checkout-time
* availability) and anything else needing the payment-method list (e.g.
* PaymentServiceProvider's Lunar\Facades\Payments shim, order screens
* showing a transaction's driver) read from, so the table is fetched once
* per cache lifetime rather than once per caller/request. Mirrors
* Modules\Core\Localization\Services\LanguageCache's exact shape.
*
* Cached forever, invalidated via forget() by
* Modules\Core\Payment\Observers\FlushPaymentMethodCache on
* PaymentMethod::saved()/deleted() — no bespoke Created/Updated/Deleted
* event trio needed, unlike LanguageCache's (Language is a Lunar-owned
* model reacted to indirectly); PaymentMethod is entirely our own model,
* so a plain Eloquent observer is the direct route.
*/
class PaymentMethodCache
{
private const CACHE_KEY = 'core.payment.methods';
public function all(): Collection
{
return Cache::rememberForever(
self::CACHE_KEY,
fn () => PaymentMethod::query()->orderBy('position')->get(),
);
}
public function forget(): void
{
Cache::forget(self::CACHE_KEY);
}
}
@@ -0,0 +1,82 @@
<?php
namespace Modules\Core\Payment\Services;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Event;
use Modules\Core\Payment\Events\PaymentMethodCreated;
use Modules\Core\Payment\Events\PaymentMethodDeleted;
use Modules\Core\Payment\Events\PaymentMethodUpdated;
use Modules\Core\Payment\Models\PaymentMethod;
/**
* The single write (AND read) gateway for PaymentMethod — every Filament
* resource/action calls this, not PaymentMethod::create()/update()/delete()
* directly, so cache invalidation is one explicit step colocated with the
* mutation (not hidden in a model observer) and every admin change to a
* payment method dispatches a matching event, the same convention
* Modules\Core\Cart\Services\CartService already established for its own
* mutating methods.
*
* list() is what PaymentMethodCache actually reads through — see that
* class for why this needs caching at all (Modules\Core\Checkout\
* Services\CheckoutService and PaymentServiceProvider's Lunar\Facades\
* Payments shim both read the full payment-method list on the hot path).
*/
class PaymentMethodService
{
public function __construct(
private readonly PaymentMethodCache $cache,
) {}
/**
* @return Collection<int, PaymentMethod>
*/
public function list(): Collection
{
return $this->cache->all();
}
/**
* @param array<string, mixed> $data
*/
public function create(array $data): PaymentMethod
{
$method = PaymentMethod::create($data);
$this->cache->forget();
Event::dispatch(new PaymentMethodCreated($method));
return $method;
}
/**
* @param array<string, mixed> $data
*/
public function update(PaymentMethod $method, array $data): PaymentMethod
{
$old = $method->only(array_keys($data));
$method->update($data);
$this->cache->forget();
Event::dispatch(new PaymentMethodUpdated($method, $old));
return $method;
}
public function delete(PaymentMethod $method): void
{
$snapshot = $method->only([
'id', 'type', 'name', 'driver', 'capture_mode', 'position', 'enabled',
]);
$method->delete();
$this->cache->forget();
Event::dispatch(new PaymentMethodDeleted($snapshot));
}
}

Some files were not shown because too many files have changed in this diff Show More