Compare commits

...
29 Commits
Author SHA1 Message Date
arvanitakis 2cc6f5e5f0 Bump Version to 0.18.0 2026-09-16 00:06:04 +03:00
arvanitakis 0babc6a96d Fix: Locking User on Login to manage otp attempts 2026-09-16 00:05:53 +03:00
arvanitakis 89a3d4bbad Merge branch 'master' into customer 2026-09-15 23:57:07 +03:00
arvanitakis ccb2666495 Bump Version to 0.17.5 2026-09-15 23:52:48 +03:00
arvanitakis 97004234f0 Feat: Adding Translations to Payment and Shipping Methods, removing unecessary shipping method fulfillment type 2026-09-15 23:51:10 +03:00
arvanitakis ea73cc3562 Feat: Translating States and Countries For Greece 2026-09-15 22:30:57 +03:00
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 409e8204f6 Updating Changelog 2026-09-15 16:02:20 +03:00
arvanitakis 8472649905 Feature: Customer Account Services 2026-09-15 16:01:28 +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
134 changed files with 5966 additions and 689 deletions
+524 -15
View File
@@ -4,19 +4,482 @@ 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.18.0] - 2026-09-16
### Added
Customer-portal backend groundwork — no routes/controllers/views yet (a storefront-facing UI is
3dealer's job once a frontend designer picks it up), but the boboko-owned services it needs to
call now exist:
- `Modules\Core\Auth\Services\UserOtpService::validate()` now actually logs the shopper in
(`Auth::login()`, `web` guard) — previously it only returned the `User` model with no session
established and no route/controller anywhere ever called it (the checkout page's "Login" tab
was a disabled placeholder). `Auth::login()` alone is enough to merge/associate any active
guest cart too — it fires `Illuminate\Auth\Events\Login`, which Lunar's own
`Lunar\Listeners\CartSessionAuthListener` (registered unconditionally in core, no opt-in
needed) already reacts to, honoring `config('lunar.cart.auth_policy')` (`'merge'` by default).
An earlier draft of this also called `Cart::associate()` directly from this service — removed
as redundant and actually wrong: it ran a second, separate association with a hardcoded
`'merge'` policy that ignored whatever a consumer had actually set `auth_policy` to. Also fixed
an unbounded brute-force window: a 6-digit code (1M combinations, was guessable for its full
10-minute expiry with no attempt cap) now invalidates itself after 5 wrong guesses
(`users.otp_attempts`, new column), forcing a fresh code request rather than leaving a live one
guessable indefinitely.
- `Modules\Core\Auth\Events\CustomerLoggedIn` — dispatched on every successful OTP login (new
user or returning), for a storefront to hook into (e.g. post-login redirect, analytics).
- `Modules\Core\Customer\Services\CustomerAccountService` — the storefront-facing "My Account"
API (mirrors `CartService`/`CheckoutService`'s shape): `orders()` (paginated, placed orders
only), `order()`, `addresses()`, `createAddress()`/`updateAddress()`/`deleteAddress()`,
`updateProfile()`. Every method is scoped to the given user's own `latestCustomer()` — there
is no method that accepts a bare order/address id without also requiring the owning user, so a
controller built on top of this can't leak one customer's data to another by trusting a
client-supplied id alone (verified live: a second customer attempting to read/edit the first's
address or order gets `AddressNotFoundException`/`OrderNotFoundException`, not the record).
### Fixed
- `Modules\Core\Auth\Services\UserOtpService::validate()`'s wrong-guess counter (`otp_attempts`)
was read-check-increment-saved with no locking — two guesses fired in parallel for the same
user could each read the same pre-increment value and both save past `max_attempts`, letting an
attacker exceed the 5-guess lockout by parallelizing requests instead of sending them serially.
Now wrapped in a `DB::transaction()` with `lockForUpdate()` on the user row, so concurrent
guesses serialize correctly against the shared counter.
- The OTP code comparison used a plain `!=` rather than a timing-safe comparison. Now
`hash_equals()`.
## [0.17.5] - 2026-09-15
### Added
- Greek translations for `Lunar\Models\Country`/`State` reference data (`lang/el/countries.php`,
`lang/el/states.php`), keyed by the exact English spellings Lunar's own installer seeds for
Greece (fetched from `data.lunarphp.io/countries+states.json`). Loaded via
`loadTranslationsFrom()` under the `core::` namespace — a plain lang file, not
`Modules\Core\Localization`'s DB-backed `TranslationService`, since this is fixed reference
data, not admin-editable UI copy. A consuming app's storefront looks these up itself (e.g.
`__('core::countries.'.$country->name)`) — core has no storefront UI of its own to wire this
into.
- `Modules\Core\Order\Filament\Extensions\OrderActionsExtension::fixCaptureAction()` — reroutes
the backoffice "Capture" header action through `Modules\Core\Payment\Support\
TransactionDriverAdapter::capture()`, the same app-level payment pipeline checkout-time captures
use, instead of vendor Lunar's `Lunar\Models\Transaction::capture()` (which resolved
`Lunar\Facades\Payments`, an entirely separate, unused driver registry, and never dispatched
`Modules\Core\Payment\Events\PaymentCaptured`).
- `Modules\Core\Payment\Drivers\StripePaymentDriver::cardMetaFromIntent()` — extracts card
brand/last-four digits from the Stripe PaymentIntent's `latest_charge`, populated into
`PaymentResult::$meta` and mapped onto `Transaction.card_type`/`last_four` by
`Modules\Core\Order\Services\TransactionRecorder`. Fixes the admin activity log's "Payment of
:amount on card ending :last_four" line rendering with no digits, on both checkout-time and
manual captures. Only applies to transactions recorded after this change.
- `PaymentMethod.name` and `Lunar\Shipping\Models\ShippingMethod.name` are now locale-keyed JSON
columns, rendered in Filament via Lunar's own `Lunar\Admin\Support\Forms\Components\
TranslatedText` — one input per configured `Language` row, same shape/resolution as
Product/Collection names. Existing plain-string rows are preserved under the store's default
language on migration. `ShippingMethod` has no model cast/`ModelManifest` extension point
available (vendor table, `Contracts\ShippingMethod` exists but is never bound by the package),
so its translation is decoded/encoded at the Filament field boundary and via the new
`Modules\Core\Shipping\Support\ShippingMethodName::resolve()` helper, rather than a model cast.
- `Modules\Core\Shipping\Contracts\DeclaresFulfillmentType` — lets a shipping rate driver declare
whether it fulfils via carrier delivery or in-store pickup as a hardcoded fact about the driver
(`AcsRateDriver`, `BoxNowRateDriver` both declare `'carrier'`), instead of asking a merchant to
also pick "Carrier delivery" on every row regardless of driver. The merchant-facing "Fulfillment
type" Select (`ShippingMethod.data['fulfillment_type']`) now only appears for
table-rate-shipping's generic drivers (flat-rate, ship-by, free-shipping), which are genuinely
ambiguous, and moved next to `charge_by` instead of trailing at the end of the form,
disconnected from the decisions it relates to. `Modules\Core\Shipping\Support\
FulfillmentType::resolve()`/`isStorePickup()` is the new single source of truth, replacing a
direct `data['fulfillment_type']` read in `Order::isStorePickupOrder()`.
### 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`), `status` advances to the next step
in the order's flow, but only when it's still exactly `awaiting_payment`, so a duplicate/delayed
capture event never regresses an order staff already moved further.
- `Lunar\DataTypes\ShippingOption::$collect` (the flag `docs/checkout.md` documents as the
mechanism for detecting a pickup option at checkout) was never actually set by any shipping rate
driver — `Modules\Core\Shipping\Concerns\ResolvesFixedPricing` now populates it from the same
`FulfillmentType` resolution `Order::isStorePickupOrder()` uses, closing a real gap between
documented and actual behavior.
### 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.
- 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`, `Modules\Core\Payment\Models\StripePaymentIntent`, `Modules\Core\Payment\Http\
Middleware\StripeWebhookMiddleware`, and a first-party copy of the vendor's
`create_stripe_payment_intents_table` migration (guarded with `Schema::hasTable()`). No behavior
change for consuming apps.
## [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.
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
@@ -32,9 +495,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
`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
$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
_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,
@@ -55,6 +518,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [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
@@ -64,13 +528,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
(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
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
$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
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
@@ -99,6 +563,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
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
@@ -129,9 +594,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- `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
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()`:
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
@@ -140,7 +605,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
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
?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
@@ -175,18 +640,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- 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
_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
_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
@@ -214,15 +680,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [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
- **Breaking:** `Payment` is now a genuinely standalone module — no direct calls into `Checkout`/`Order`, no reaching into their Eloquent models, communication only via events. The entire old `confirm()`-based flow is gone: `Modules\Core\Payment\Contracts\PaymentDriver` (and the already-stale `Modules\Core\Checkout\Contracts\PaymentDriver` duplicate), `Checkout\Events\PaymentConfirmed`, `Payment\Contracts\InitiatesPayment`, `Payment\DataTransferObjects\PaymentInitiation`, `Payment\Enums\PaymentInitiationMode`, `Payment\Events\PaymentSucceeded`/`PaymentFailed`, `Payment\Events\OrderPaymentStatusResolved`, and `Payment\Exceptions\PaymentNotConfirmedException` are all deleted. This flow was non-functional on `master` before this release — `CheckoutService::confirmPayment()` dispatched an event nothing listened for, so no order was ever placed after payment.
- **Breaking:** Every payment operation is now its own explicit, opt-in contract, modeled on how real gateways (Stripe, Mastercard's own gateway, Nexi) actually split these operations — see `docs/payments.md`: `Modules\Core\Payment\Contracts\SupportsPay` (atomic authorize+capture), `SupportsAuthorization` (hold only), `SupportsCaptures` (settle a prior hold), `SupportsVoids` (release a prior hold without settling), `SupportsRefunds` (reverse settled funds), `HandlesPaymentCallback` (resolve an async pay()/authorize() later, from a webhook), and `Configurable` (`isConfigured()`, split out of the old single `PaymentDriver` interface). A driver implements only the operations its gateway actually supports.
- **Breaking:** Every amount flowing through these contracts is `Lunar\DataTypes\Price` (Lunar's own bundled minor-unit-value + `Currency` type) — never a bare `int` paired separately with a `Currency`. Each driver converts at its own boundary (e.g. `StripeManager::toStripeAmount()`/`fromStripeAmount()`); `Payment` itself only ever speaks Lunar's `Price`.
@@ -231,6 +700,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- `config/payment.php`'s `cash-on-delivery` entry gains `capture_mode` (`'pay'`, since `OfflinePaymentDriver` only implements `SupportsPay`) and `captured_status` (`'payment-offline'`, replacing the previously dead `'authorized' => 'awaiting-payment'` key, which nothing ever read).
### Added
- `Modules\Core\Payment\DTOs\PaymentResult` — the one return shape every operation (`pay`, `authorize`, `capture`, `void`, `refund`, `handleCallback`) produces, regardless of gateway: `status` (`Modules\Core\Payment\Enums\PaymentResultStatus`: `Succeeded`/`Failed`/`Pending`), `reference`, `amount` (a `Price`), `failureReason`, `retriable` (real on Stripe/Mastercard's own soft-decline classification, always `false` on Nexi — it has no such signal), `raw` (the untouched gateway response, for audit), `meta`, and `continuation` (see below).
- `Modules\Core\Payment\DTOs\PaymentContinuation` / `Modules\Core\Payment\Enums\PaymentContinuationType` — what a caller does next with a `Pending` `PaymentResult`, gateway-agnostically (`Redirect` or `ClientSecret`), so a storefront controller never needs gateway-specific knowledge of e.g. Stripe's own `PaymentIntent` fields to drive a 3-D Secure/redirect continuation.
- Eight new events, one terminal pair per operation, replacing the old single `PaymentSucceeded`/`PaymentFailed`: `PaymentAuthorized`/`PaymentAuthorizationFailed`, `PaymentCaptured`/`PaymentCaptureFailed`, `PaymentVoided`/`PaymentVoidFailed`, `PaymentRefunded`/`PaymentRefundFailed`. `PaymentCaptured` is deliberately the same event whether money was taken via `pay()` (one gateway call) or `authorize()`→`capture()` (two calls) — "a payment has been captured" is the same business fact either way. Every event carries `{type, result: PaymentResult, context}` — `context` is an opaque bag the caller hands in and gets back untouched, so `Payment` never needs to know what a `Cart` or `Order` is.
@@ -240,22 +710,26 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- `docs/payments.md` — full design notes: the operation/contract table cross-referenced against Mastercard/Stripe/Nexi's real APIs, why `PaymentResult` normalizes only what every gateway can always provide, the async-correlation pattern, and what's explicitly out of scope (a `Transaction`-writing listener, the `stripe` config entry, frontend Stripe Elements integration).
### Fixed
- `Modules\Core\Checkout\Services\CheckoutService::selectPaymentMethod()` crashed (`Call to a member function toArray() on null`) the first time it ran against a cart whose `meta` column was still a genuine SQL `NULL` (any freshly-created cart) — `Cart::$meta`'s `AsArrayObject` cast returns `null`, not an empty array-like object, for a `null` column. Fixed with a null-safe fallback.
- `Modules\Core\Shipping\Carriers\Acs\AcsRateDriver`/`BoxNowRateDriver` referenced `Lunar\Shipping\DTOs\ShippingOptionRequest`, a namespace that doesn't exist in the installed `lunarphp/table-rate-shipping` version (the real class is `Lunar\Shipping\DataTransferObjects\ShippingOptionRequest`) — crashed `Illuminate\Support\Manager`'s interface-compatibility check the moment anything touched `ShippingManager::getSupportedDrivers()`, including simply adding a line to a cart (via `Modules\Core\Shipping\Listeners\FlushLivePricingCache`).
## [0.13.1] - 2026-09-03
### Added
- `Modules\Core\Order\Listeners\RecordPaymentTransaction` — writes the `lunar_transactions` row for a successful `PaymentCaptured`/`PaymentAuthorized`/`PaymentVoided`/`PaymentRefunded` event, via a new `Modules\Core\Order\Services\TransactionRecorder` (moved here from `Payment\Services`, and rewritten to take a `PaymentResult` directly instead of the deleted `CaptureResult`/`RefundResult` DTOs — `Payment` never writes to `Order`'s models, `Transaction.order_id` being required is exactly why this lives in `Order`, same reasoning as `ApplyResolvedPaymentStatus`). Closes a real gap introduced in `0.13.0`: `Order::paymentStatus()` (which derives its answer entirely from `$order->transactions`) always resolved to `PaymentStatus::Offline` — its "no transactions at all" fallback — regardless of what actually happened, since nothing had ever written a row. Verified live: a captured offline payment now produces a `type: capture` transaction and `Order::paymentStatus()` correctly resolves to `captured`.
## [0.12.1] - 2026-09-03
### Fixed
- `Modules\Core\MigrateImport\Shopify\ShopifyExportImporter` now attaches a variant's `Variant Image` CSV column to that `ProductVariant`'s own `images()` media pivot (`media_product_variant`, `primary`/`position`). Previously the variant image was never read at all — every image from the CSV, including ones the export clearly scopes to one specific variant, went only into the product's own top-level gallery, so a variant swatch/option change had no way to show its own photo.
- `Modules\Core\MigrateImport\Shopify\Resolvers\ProductOptionResolver::resolveOption()` now sets `label` (same value as `name`) when creating a `Lunar\Models\ProductOption`, not just `name`. A `ProductOption` with a null `label` crashes Lunar's own `ProductOptionIndexer::toSearchableArray()` (`foreach()` on `null`) the moment that option gets reindexed — every option created by the importer before this fix has a null `label` and needs a wipe-and-reimport (see `docs/shopify-reimport.md`, new in this release) to pick up the fix, since `firstOrCreate()` never revisits an already-existing row.
- `product_reviews.product_id`'s foreign key had no `ON DELETE` clause, so deleting a reviewed `Product` threw a constraint violation instead of the review going with it, unlike every other product-dependent table. New migration adds `cascadeOnDelete()`.
### Added
- `Modules\Core\Catalog\Services\ProductIndexer::mapVariant()` now embeds `gtin`, `mpn`, `ean`, `backorder`, `unit_quantity`, `shippable`, `tax_ref`, and `dimensions` (length/width/height/weight/volume, each with `value`+`unit`) on every indexed variant — previously only `id`/`sku`/`stock`/`purchasable`/`options`/`prices`/`media` were embedded, so a search result or filter needing any of these had no way to get at them without a separate Postgres query per variant.
- `ProductIndexer::toSearchableArray()` adds a top-level, filterable `skus` field (every variant's SKU, deduplicated) — filtering/matching by SKU no longer requires reaching into the nested `variants` array.
- `docs/shopify-reimport.md` — runbook for wiping every imported product (cascading through Lunar so Meilisearch documents go too) and re-running the importer from scratch, needed whenever a fix like the two above only takes effect on newly-created rows.
@@ -263,12 +737,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [0.12.0] - 2026-09-03
### Changed
- **Breaking:** `Modules\Core\Catalog\Services\ProductService::list()` now returns `Modules\Core\Catalog\DTOs\ProductListingResult` (`->products`: the same `Illuminate\Pagination\LengthAwarePaginator` as before, `->priceBounds`: a new `Modules\Core\Catalog\DTOs\PriceSliderBounds`) instead of returning the paginator directly. A caller doing `$service->list(...)->items()`/`->through(...)` must update to `$service->list(...)->products->items()`/`->through(...)`. This collapses what used to be two separate calls a controller had to orchestrate itself (`list()` for products, `priceRange()` + manual floor/ceil/"is this actually filtered" math for the slider) into one.
- **Breaking:** `Modules\Core\Catalog\Services\ProductSearchService::search()`'s signature changed from `search(string $query, ?string $locale = null)` to `search(string $query, ?ProductFilters $filters = null, ?ProductSort $sort = null)` — the `$locale` parameter is gone (see "every configured language, always" below); `$filters`/`$sort` apply the same `Modules\Core\Catalog\Support\ProductFilterBuilder`/`ProductSort::toMeilisearchSort()` semantics `ProductService::list()` already used, so a text search can now be narrowed by price/brand/stock and sorted the same way a category listing can.
- `ProductSearchService` now targets every configured store language's fields on every search (`Lunar\Models\Language::all()`), not just the current request locale plus the store's default language. The old `{current, default}` pairing silently stopped catching anything outside those two locales whenever they were equal (a single-language store, or a shopper browsing in the default language) — always searching every configured language closes that gap in both directions. See `docs/product-search.md`.
- Extracted `Modules\Core\Catalog\Services\ProductService`'s private `buildFilter()` into a new standalone `Modules\Core\Catalog\Support\ProductFilterBuilder`, so `ProductSearchService` can apply the exact same Meilisearch filter-clause semantics to a text query, instead of reimplementing filter-building a second time.
### Added
- `Modules\Core\Catalog\Services\ProductService::priceSliderBounds()` — `priceRange()` rounded to whole currency units (floor/ceil) plus whether the given selected min/max actually narrows it, returned as a `PriceSliderBounds` DTO. Used internally by `list()` now; also callable directly for a caller (e.g. a text-search results page) that needs slider bounds without a full `list()` call.
- `Modules\Core\Catalog\Services\ProductService::priceRange()` gained an optional `string $query = ''` parameter, so a caller can scope the price range to a text search's own matches (pass the shopper's search text) instead of always spanning the whole catalog.
- `Modules\Core\Catalog\Services\ProductService::random(int $limit)` — random products still scoped to the Meilisearch index's own channel/status visibility, unlike a raw `Product::inRandomOrder()` (which has no notion of that filtering). Meilisearch has no `ORDER BY RANDOM()` equivalent, so this fetches every matching id only (`attributesToRetrieve: ['id']`), shuffles in PHP, then fetches the full localized documents for just the ids picked, restoring the shuffled order afterward (Meilisearch's `id IN [...]` filter doesn't preserve list order on its own).
@@ -279,11 +755,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [0.11.1] - 2026-09-01
### Fixed
- `Modules\Core\Catalog\Services\RecommendationService::recommend()` built its result with the base `Illuminate\Support\Collection` (`collect()`) instead of `Illuminate\Database\Eloquent\Collection`, even though every element is a `Product` model. `ProductIndexer::toSearchableArray()` calling `->load(['media', 'variants.prices'])` on that result threw `BadMethodCallException: Method Illuminate\Support\Collection::load does not exist` — silently failing every `MakeSearchable` queue job for a saved product (visible only as `FAIL` in the queue log, with the real exception in `storage/logs/laravel.log`). Fixed by having `RecommendationService` accumulate into a real `Eloquent\Collection` from the start.
## [0.11.0] - 2026-09-01
### Added
- `Modules\Core\Catalog\Services\RecommendationService` — computes "related products" for a given product as a configurable, ordered chain of strategies (`config('catalog.recommendation_rules')`), not one hardcoded rule. Tops up from each successive rule until the limit (default 4) is reached or every rule is exhausted — e.g. 3 products from a same-category rule plus 1 from a random fallback — deduplicated across rules so the same product is never returned twice. Ships with `Modules\Core\Catalog\Recommendations\SameCategoryRule` (other products sharing the source product's first collection) and `RandomRule` (the universal fallback, placed last in the default chain). A new rule is just a class implementing `Modules\Core\Catalog\Contracts\RecommendationRule`. Documented in `docs/product-recommendations.md`.
- `Modules\Core\Catalog\Services\ProductIndexer` embeds the result directly into each product's own Meilisearch document as `recommendations: [{id, name, price, image}, ...]` (`recommendations.id` filterable) — a product detail page renders its "related products" section with zero extra queries, same reasoning as the existing `collections` field. Deliberately embeds an `id` for the view to build a locale-correct URL from, not a resolved `href` — `product.show` is locale-prefixed, so a URL baked in at index time would only be correct for whichever locale happened to be active during that index run.
- `Modules\Core\Catalog\Events\ProductSaved`/`ProductDeleted`, dispatched from `Product::saved()`/`Product::deleted()` in `CatalogServiceProvider` (the latter fires for both a soft delete and a force delete, matching Scout's own `unsearchable()` trigger point) — feed `Modules\Core\Catalog\Listeners\ReindexProductsRecommendingProduct`, which reverse-searches Meilisearch for every product currently recommending the changed/deleted one (`recommendations.id = "..."` — there's no Postgres relation for this, a recommendation only exists inside the index) and re-indexes them via Scout's own `->searchable()`. Product creation is deliberately not hooked into this: a new product not yet appearing as a recommendation elsewhere is an accepted staleness window, the same tradeoff already documented for `in_stock`/`price` — see `docs/product-recommendations.md`.
@@ -292,28 +770,33 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [0.10.1] - 2026-09-01
### Added
- `Modules\Core\Localization\Services\StorefrontLabels::all()` gains three keys found missing from `3dealer`'s actual `storefront.*` translation usage: `shop.price_min`, `shop.price_max`, `shop.reset` (the price-filter sidebar's min/max labels and its reset link). Picked up by `InstallLunarCommand`'s existing per-key upsert — re-running `lunar:install` on an already-installed store adds only these three rows, leaving everything already seeded or admin-edited untouched.
## [0.10.0] - 2026-08-31
### Changed
- **Breaking:** Upgraded `lunarphp/lunar`, `lunarphp/core`, `lunarphp/stripe`, `lunarphp/table-rate-shipping`, and `lunarphp/search` to `1.5.0`, and `filament/filament` to `v4.12.6` — the first Filament v4 admin panel on this codebase. `lunarphp/filament3-2fa` and `kalnoy/nestedset` are gone, replaced by Filament v4's native two-factor auth and `lunarphp/nestedset`. Ran Filament's automated `filament-v4` migration tool across `src/`, then hand-fixed three bugs it introduced or left behind: a stale `$infolist` variable reference in `CartResource`'s `ViewCart` page (the parameter had been renamed to `$schema` but the body wasn't updated), `ShippingMethodResourceExtension` rewritten to call `getDefaultChildComponents()` (returns `array|Schema`) instead of the type-safe `getChildComponents()` (always `array<Component>`), and — unrelated to the tool, but surfaced by the same PHP version bump — `InvalidCouponException`'s `readonly $code` property illegally shadowing the built-in `Exception::$code`, renamed to `$couponCode`. `LunarStaff::addActivitylogExcept()` updated for the renamed `two_factor_secret`/`two_factor_recovery_codes` staff columns (now `app_authentication_secret`/`app_authentication_recovery_codes`; `two_factor_confirmed_at` removed). Consuming apps must run `composer update boboko/core --with-all-dependencies` and `php artisan migrate`.
### Added
- `Modules\Core\Checkout\Contracts\PaymentDriver` — the abstraction every payment provider implements: `confirm(Cart $cart, string $type, string $fingerprint, array $data): Order` and `isConfigured(): bool`. A driver only ever calls `CheckoutService::placeOrder()` once it has, by whatever mechanism is native to that gateway, independently confirmed payment — never Lunar's raw `Cart::createOrder()`. This is what lets the storefront checkout sequence stay uniform regardless of which provider is active: set addresses, select shipping, hand off to whichever driver is configured, and the driver decides when (or whether) the order gets created.
- `Modules\Core\Payment\Drivers\OfflinePaymentDriver` — shared by every payment type with no real gateway to confirm against (`cash-in-hand`, `cash-on-delivery`): places the order immediately via `CheckoutService::placeOrder()`, then sets the order status from `config("lunar.payments.types.{$type}.authorized")` using the type actually confirmed, not a hardcoded key, since one driver instance serves multiple types.
- `Modules\Core\Payment\Drivers\StripePaymentDriver` — a fork, not a decoration, of `lunarphp/stripe`'s `StripePaymentType::authorize()`: that method is `final` and calls `Cart::createOrder()` directly with no seam to redirect into our fingerprint-checked `placeOrder()`, so this class reimplements its logic (intent retrieval, capture-on-policy, status mapping via `UpdateOrderFromIntent`) with that one substitution. Throws the new `Modules\Core\Payment\Exceptions\PaymentNotConfirmedException` on anything short of a genuinely confirmed payment intent — never falls through to placing an order on ambiguity.
- `CheckoutService::getPaymentMethods(): array` — 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 driver reports `isConfigured()` (e.g. Stripe with no API key set is never offered, regardless of the enabled toggle). `selectPaymentMethod(string $type)` and `confirmPayment(string $type, array $data)` both validate against this list, throwing the new `UnknownPaymentTypeException` for a type that isn't currently offered — re-checked in `confirmPayment()` too, since a type could be disabled between selection and confirmation.
- `CheckoutService::selectPaymentMethod()` snapshots `Cart::fingerprint()` into `cart->meta['checkout_fingerprint']` *after* saving the chosen type and recalculating — 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. `confirmPayment()` reads this stored fingerprint internally rather than taking one as a parameter: a storefront should never need to know `Cart::fingerprint()` exists or capture it at exactly the right moment itself.
- `CheckoutService::selectPaymentMethod()` snapshots `Cart::fingerprint()` into `cart->meta['checkout_fingerprint']` _after_ saving the chosen type and recalculating — 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. `confirmPayment()` reads this stored fingerprint internally rather than taking one as a parameter: a storefront should never need to know `Cart::fingerprint()` exists or capture it at exactly the right moment itself.
- `Modules\Core\Payment\Models\PaymentMethod` — one DB row per payment type key (matching `config('lunar.payments.types')`), `enabled` boolean plus a `data` jsonb column (starting with `fee`, the flat cash-on-delivery surcharge) — mirrors Lunar's own `Discount` model (a single jsonb column of keyed settings, not a fixed column per setting or a separate conditions table). Seeded idempotently by `InstallLunarCommand` (skip-if-exists per type, safe to re-run after installing a new payment-provider package), always `enabled: false` — a newly-seeded type shouldn't go live for shoppers before staff have configured and reviewed it. Admin-editable via the new `PaymentMethodResource` (inline enabled toggle, modal fee editor) under Settings.
- `ApplyCashOnDeliveryFee` now reads its surcharge from `PaymentMethod` instead of static config, so it's admin-editable without a deploy.
### Fixed
- `CashOnDeliveryPaymentDriver` renamed to `OfflinePaymentDriver` and generalized to work for any offline-style type — it previously hardcoded `'cash-on-delivery'` when reading the post-placement order status from config, which would have silently read the wrong type's status the moment a second offline type (`cash-in-hand`) used it.
## [0.9.0] - 2026-08-29
### Added
- `Modules\Core\Cart\Services\CartService` — the boboko-owned API for all cart mutation, wrapping Lunar's `CartSession`/`Cart` primitives: `addLine()`, `updateLine()`, `removeLine()`, `clear()`, `applyCoupon()`/`removeCoupon()` (throws `InvalidCouponException` on an invalid code), and save-for-later (`saveForLater()`/`moveToCart()`/`activeLines()`/`savedLines()`, backed by a `meta.saved_for_later` flag and a new `Modules\Core\Cart\Pipelines\ZeroSavedForLaterPrice` cart-line pipeline step that zeroes a saved line's price so it's excluded from cart totals without being removed). Dispatches 8 real domain events (`CartLineAdded`/`Updated`/`Removed`/`Saved`/`MovedToCart`, `CartCleared`, `CartCouponApplied`/`Removed`) — none have a listener yet, built so a future concern (analytics, recovery) has something to attach to. Documented in `docs/cart.md`.
- `Modules\Core\Checkout\Services\CheckoutService` — the boboko-owned API for the checkout stage (address → shipping selection → order placement), sitting between `CartService` and `Order`: `setShippingAddress()`/`setBillingAddress()`, `getShippingOptions()`/`selectShippingOption()` (throws the new `InvalidShippingOptionException` on an identifier that doesn't resolve — previously a silent no-op), and `placeOrder(string $fingerprint)` (the fingerprint is mandatory, not optional — forces re-confirmation via Lunar's own `FingerprintMismatchException` if the cart changed since the shopper last saw its total). Dispatches `ShippingAddressSet`/`BillingAddressSet`/`ShippingOptionSelected`/`OrderPlaced`, each carrying richer, already-resolved payload (e.g. the resolved `ShippingOption`, not just its identifier) than `CartService`'s events. No exception wrapping otherwise — Lunar's own `CartException`/`FingerprintMismatchException` are already the right shape for a storefront to render as form errors. Documented in `docs/checkout.md`.
- `Modules\Core\Cart\Filament\Resources\CartResource`'s list view now classifies every cart into one of four states — **Ongoing**, **Abandoned Cart**, **Abandoned Checkout**, **Completed** — instead of the previous two-tab Abandoned/Completed split, distinguishing a cart that never reached checkout from one that has a started-but-unplaced order (mirrors the real distinction in Lunar's own `Cart::scopeActive()`). Abandonment threshold is a fixed, configurable cutoff (`config('core.cart.abandoned_after')`, default 1 hour). Added a customer hyperlink (list column + a "View Customer" header action on the view page, both pointing straight at `customers/{id}` via the plain `customer_id` column, no extra query via the `customer` relation).
@@ -323,17 +806,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- `AcsRateDriver::resolveLivePrice()` now falls back to the rate's own configured static price if the live ACS API call fails (previously: the shipping option silently disappeared from the list on any API error, including a brief outage). `ManageShippingRates` (our Filament subclass of the vendor rates page) now allows a static price to be configured and saved on a "live" rate specifically for this fallback — previously those fields were hidden and discarded on save for any live-priced rate.
### Fixed
- Fixed a crash (`Attempt to read property "price" on null`) opening/editing a live-priced shipping rate with no fallback price configured yet — the vendor `ManageShippingRates` page's `afterStateHydrated` callback for the price field had no null-guard for a rate with zero `basePrices`, which is now the routine case for an unconfigured live rate.
- Fixed the Filament admin panel's home URL (`/boboko/home`) incorrectly resolving to the Shipping module's `ManagePickupManifests` page instead of the Dashboard — Filament falls back to the first item of the first registered navigation group when no explicit `homeUrl()` is set, and `ManagePickupManifests` had no `navigationGroup`/`navigationSort` of its own. Fixed via explicit `navigationGroup = 'Sales'` / `navigationSort = 100`, placing it after Sales in the nav instead of first overall.
## [0.8.0] - 2026-08-27
### Added
- `Modules\Core\Cart\Filament\Resources\CartResource` gives staff read-only visibility into carts in the Filament admin panel — Lunar ships no cart admin view at all. Scoped to carts with a known `user_id`/`customer_id` (an anonymous guest cart carries no identity staff could act on); list table shows customer/user, line/item counts (via Filament's built-in `->counts()`/`->sum()`, no per-row queries), currency, and last activity. List page has only two tabs, **Abandoned** (default active) and **Completed** — no "All" tab, so the list never runs an unfiltered fetch over the whole table. They key off whether the cart has a **placed** order (`orders.placed_at IS NOT NULL`), not `Cart::completed_at` — that column is declared/cast on the model but never actually written anywhere in Lunar core, so it's not a real signal; "Abandoned" mirrors Lunar's own `Cart::scopeActive()`. `getNavigationBadge()` shows the abandoned-cart count in the sidebar via a single `COUNT(*)` query, no rows loaded. View page runs `$cart->calculate()` once so line/cart totals (plain public properties Lunar never persists) are populated, without paying that cost per row in the list. Documented in `docs/cart.md`.
## [0.7.0] - 2026-08-27
### Added
- `Modules\Core\Catalog\Services\CollectionService` provides category browsing/nav AND single-collection lookup from Meilisearch, mirroring `ProductService` exactly (`list()`, `getById()`, `getBySlug()`, same locale-resolution logic). `Modules\Core\Catalog\Services\CollectionIndexer` extends Lunar's own `Lunar\Search\CollectionIndexer` (which only carried `id`/`name`/`created_at`) to add `parent_id`, `_lft`/`_rgt` (nested-set tree position, filterable/sortable), `collection_group_id`, `slugs`, and `thumbnail`. `Modules\Core\Catalog\DTOs\CollectionFilters` supports `parentId` (children of a specific collection), `groupId`, and `rootOnly` (top-level collections, `parent_id IS NULL` — mutually exclusive with `parentId`). `Modules\Core\Catalog\Enums\CollectionSort` adds `Position` (`_lft:asc`, the recommended default for nav/tree UIs — matches admin arrangement order), `Name`, `Newest`. Must be registered in a consuming app's `config/lunar/search.php` (`Lunar\Models\Collection::class => CollectionIndexer::class`), same as `ProductIndexer`. Documented in `docs/collections.md`.
- `Modules\Core\Localization\Services\StorefrontLabels::all()` extracts the default storefront UI label list out of `InstallLunarCommand` into its own class, and adds every previously-missing key (`nav.contact`, `product.description`/`no_image`/`read_more`/`reviews`, `customer_reviews`, `pagination.*`, `review.*`, `shop.*`) that had already been seeded manually in some stores but was absent from the command's own list — bringing the code-side default back in sync with what a real store actually has. `InstallLunarCommand::seedStorefrontLabels()` now does a **per-key upsert** instead of an all-or-nothing "only seed if the group is empty" guard: a key already present in the database (including one an admin has since edited via the Filament **Language Lines** resource) is left untouched, and only missing keys are created via `TranslationService::create()`. This makes it safe to add new keys to `StorefrontLabels::all()` later and re-run `lunar:install` on an already-installed store without either silently skipping the new keys (the old guard's behavior) or reverting an admin's edits back to the hardcoded default. Documented in `docs/localization.md` ("Seeding").
- `Modules\Core\Catalog\Services\CollectionIndexer` adds `ancestors` — `[{id, name}, ...]` ordered root-first (via the newly eager-loaded `ancestors` relation) — so a breadcrumb can render directly from `CollectionService::getById()`/`getBySlug()` with zero extra queries, and `product_count` — how many products are in a collection or any of its descendants, queried from the product Meilisearch index at collection-index time via the same `collection_ids` field `ProductFilters(collectionId:)` filters against. Documented in `docs/collections.md`, including the reindex-ordering gotcha (`product_count` needs the product index reindexed first).
@@ -341,6 +827,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- `Modules\Core\Catalog\Services\ProductService::facets(string $field, ?ProductFilters $filters = null): array` returns Meilisearch facet value counts (e.g. `['Brand A' => 48, 'Brand B' => 135]`) for a discrete-value filterable field, scoped to the given filters. Uses Scout's plain `->options(['facets' => [...]])`, merged directly into the raw Meilisearch query the same way `filter`/`sort` already are — no adoption of Lunar's separate `SearchManager`/`Search` facade needed. `ProductService::priceRange(?ProductFilters $filters = null): array{min, max}` covers the numeric-field case `facets()` explicitly doesn't (`price` would otherwise return one "facet" per exact price) — backed by Meilisearch's `facetStats`, not `facetDistribution`. `priceRange()` always excludes `minPrice`/`maxPrice` from the filter it builds (via a new `$exclude` parameter on the private `buildFilter()`), so a price slider's own bounds don't shrink to whatever range is already selected on it; other filters (`collectionId`, `brand`, `inStockOnly`) still apply normally. Documented in `docs/product-listing.md`.
### Changed
- **Breaking:** Renamed the `Product` module to `Catalog`, flattened. Every class under `Modules\Core\Product\*` (`Contracts`, `DTOs`, `Enums`, `Services`, `Observers`, `Filament\Extensions`, `OptionTypes`) now lives under `Modules\Core\Catalog\*` at the same sub-path — e.g. `Modules\Core\Product\Services\ProductService` is now `Modules\Core\Catalog\Services\ProductService`, `Modules\Core\Product\DTOs\ProductFilters` is now `Modules\Core\Catalog\DTOs\ProductFilters`. Class names themselves are unchanged (still `ProductService`, `ProductIndexer`, `ProductFilters`, etc.) — only the namespace/folder moved, to make room for `Collection` as a sibling concern under the same `Catalog` umbrella rather than a disconnected top-level module. Consuming apps must update every `use Modules\Core\Product\...` import and any FQCN reference (`config/lunar/search.php`'s indexer registration, service provider bindings).
- **Breaking:** `Modules\Core\Providers\ProductServiceProvider` renamed to `Modules\Core\Providers\CatalogServiceProvider` (composer.json's provider list updated accordingly) — it now only wires `Catalog`-namespace classes (`ProductOptionTypeManager`, `ProductOptionReindexObserver`), so the name follows the same by-concern convention as `LocalizationServiceProvider`/`ReviewServiceProvider`.
- **Breaking:** `Modules\Core\Review`'s flat `Extensions/`/`Pages/` folders now nest under `Filament/`, matching the strict per-concern subfolder convention already applied to `Product`(now `Catalog`)/`Localization`. `Modules\Core\Review\Extensions\ProductResourceExtension` is now `Modules\Core\Review\Filament\Extensions\ProductResourceExtension`; `Modules\Core\Review\Pages\ManageProductReviews` is now `Modules\Core\Review\Filament\Pages\ManageProductReviews`. `Modules\Core\Review\Models\ProductReview` is unchanged.
@@ -349,29 +836,35 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [0.6.1] - 2026-08-27
### Added
- `Modules\Core\Product\Contracts\ProductOptionTypeInterface` describes how a category of `Lunar\Models\ProductOption` (e.g. "Color", "Size") behaves — what structured data its values carry in their free-form `meta` jsonb column, and how an admin edits it via Filament — without introducing a new model. Registered via `Modules\Core\Product\Services\ProductOptionTypeManager::get()->register([...])` (a singleton registry, same shape as `Modules\Core\Notification\NotificationRegistry`) from a service provider's `boot()`. An admin then picks one per `ProductOption` from an "Option Type" dropdown on the option's own edit form (added by `Modules\Core\Product\Filament\Extensions\ProductOptionResourceExtension`), stored in `ProductOption::meta['option_type']` — deliberately not tied to the option's `handle`, since a shop's own handle naming shouldn't have to match a type's key. `Modules\Core\Product\Filament\Extensions\ValuesRelationManagerExtension` hooks Lunar's own `ValuesRelationManager` (both extensions via `LunarPanel::extensions()`, registered in `CorePlugin`) to append the resolved type's meta form fields to the stock "Values" tab — no fork of Lunar's classes needed. Ships a reference implementation, `Modules\Core\Product\OptionTypes\ColorOptionType`, registered automatically by the new `Modules\Core\Providers\ProductServiceProvider`. Documented in `docs/product-options.md`.
- `Modules\Core\Product\Services\ProductIndexer::mapVariant()` now includes each option's `handle` (alongside its translated name) in a variant's indexed `options[]` — previously only the translated `option`/`value` names and `meta` were indexed, with no stable, locale-independent identifier for which option a value belongs to.
- `Modules\Core\Product\Observers\ProductOptionReindexObserver`, wired in the new `Modules\Core\Providers\ProductServiceProvider`, keeps Meilisearch in sync when a `ProductOption` or `ProductOptionValue` is saved or deleted — e.g. picking an Option Type or editing a color's hex. `ProductIndexer::mapVariant()` embeds each option value's `meta` directly into a product's indexed document, but saving the option/value never fires the *product's* own save events, so without this a changed hex would only reach the index on that product's next unrelated reindex. The observer resolves every `Lunar\Models\Product` whose variants use the changed option (or option value) via the `product_option_value_product_variant` pivot, and calls `->searchable()` on each.
- `Modules\Core\Product\Observers\ProductOptionReindexObserver`, wired in the new `Modules\Core\Providers\ProductServiceProvider`, keeps Meilisearch in sync when a `ProductOption` or `ProductOptionValue` is saved or deleted — e.g. picking an Option Type or editing a color's hex. `ProductIndexer::mapVariant()` embeds each option value's `meta` directly into a product's indexed document, but saving the option/value never fires the _product's_ own save events, so without this a changed hex would only reach the index on that product's next unrelated reindex. The observer resolves every `Lunar\Models\Product` whose variants use the changed option (or option value) via the `product_option_value_product_variant` pivot, and calls `->searchable()` on each.
### Changed
- **Breaking:** `Modules\Core\Product\Services\ProductIndexer`'s indexed `collections` field is now an array of `{id, name}` objects instead of two parallel arrays (`collections` as bare ID strings, `collection_names` as translated names joined only by array index). `collection_names` is removed. Filtering by collection now targets the nested field `collections.id` (Meilisearch supports filtering on nested object fields), not bare `collections` — `Modules\Core\Product\Services\ProductService::buildFilter()` updated accordingly; `ProductFilters(collectionId: ...)`'s public API is unchanged. Run `php artisan lunar:meilisearch:setup` then `lunar:search:index --refresh` after upgrading (see docs/product-listing.md "Gotchas").
- **Breaking:** `ProductIndexer`'s indexed `review_count`/`average_rating` top-level keys are folded into the existing `reviews` key: `reviews` is now `{items, count, average_rating}` instead of a bare array with `review_count`/`average_rating` as separate sibling keys. `reviews` (the array of review items) moved to `reviews.items`.
## [0.6.0] - 2026-08-27
### Added
- `Modules\Core\Localization\Models\LanguageLine` extends `spatie/laravel-translation-loader`'s `LanguageLine` to fall back to the store's actual default language (`LanguageCache::defaultLocale()`, backed by Lunar's `languages.default` flag) instead of the package's stock behavior of falling back to the static `config('app.fallback_locale')` — the two were previously disconnected, so changing the default language via the Filament **Languages** resource had no effect on which locale an untranslated storefront label silently fell back to. Swapped in automatically via `config('translation-loader.model')` in `LocalizationServiceProvider::register()`; no consuming app changes needed. Documented in `docs/localization.md` ("Fallback locale follows the store's default language").
### Changed
- **Breaking:** `Modules\Core\Catalog\ProductService::list()` now returns a real `Illuminate\Pagination\LengthAwarePaginator` (built from the localized Meilisearch hits) instead of a plain `array{data, meta}` — gives callers normal Laravel pagination behaviour (`$products->links()`, standard JSON serialization) without ever touching Scout's raw `paginateRaw()` response directly. `getById()`/`getBySlug()` are unaffected (still return `?array`).
- `ProductService::withLocalizedFields()` (used by `list()`, `getById()`, `getBySlug()`) no longer hardcodes `name`/`description` as the only translated fields — it now reads every `TranslatedText` attribute on `Product` from `Lunar\Base\AttributeManifest` (the same source Lunar's own indexer reads), so a store's own custom translated attributes (e.g. `seo_title`, `seo_description`) are resolved and locale-stripped automatically with no code change here. Raw `{handle}_{locale}` keys (e.g. `name_el`, `seo_title_en`) are now stripped from every returned product, not just `name_*`/`description_*`.
- Extracted `Modules\Core\Localization\Services\LanguageCache` (cached read layer over Lunar's `languages` table: `all()`, `defaultLocale()`, `availableLocales()`, `forget()`) out of `LocaleMiddleware`, which previously owned this as private/static methods despite not being middleware-specific behavior. `LocaleMiddleware` now takes `LanguageCache` via constructor injection. `LocaleMiddleware::defaultLocale()`/`forgetLanguagesCache()` (static) are removed — use `app(LanguageCache::class)` or inject `LanguageCache` directly.
### Fixed
- `Modules\Core\MigrateImport\JudgeMe\Resolvers\ProductResolver::resolve()` picked whichever `lunar_urls` row matched a slug first, which can be a soft-deleted product left behind by an earlier import batch rather than the current live one — a store can easily end up with more than one `Product` row sharing the same slug across re-imports, since a soft-deleted product's URL row isn't cleaned up. This silently broke every downstream lookup for that handle (e.g. `Modules\Core\MigrateImport\JudgeMe\JudgeMeExportImporter` logging "no product found for handle, skipping review" and dropping the row, even though a live product with that exact handle existed). Rewrote as a join against `lunar_products` — via `Product::query()`, so Eloquent's `SoftDeletes` global scope excludes trashed rows — so only a URL pointing at a live product resolves.
- `Modules\Core\Review\Models\ProductReview` had no `registerMediaConversions()` at all, unlike `Product`/`ProductVariant` which get one automatically from Lunar's own `Lunar\Base\StandardMediaDefinitions`. `Modules\Core\Search\ProductIndexer::mapMedia()` is shared across product, variant, and review media and always requests the `small` conversion — the first time a review had an attached image, indexing it threw `Spatie\MediaLibrary\MediaCollections\Exceptions\InvalidConversion`, silently failing the product's `MakeSearchable` queue job (and everything queued after it, since Scout batches). Added a matching `small` conversion (300×300, same fit/border/background as Lunar's standard one) directly on `ProductReview`.
### Breaking
- Merged `Modules\Core\Catalog` and `Modules\Core\Search` into a single `Modules\Core\Product` concern, since both existed purely to serve `Product` (browsing/filtering vs. indexing/full-text search — two services, one concern), following a stricter subfolder convention (`Contracts/`, `Enums/`, `Services/`, `DTOs/`, `Models/`, etc. per concern) going forward:
- `Modules\Core\Catalog\ProductService` → `Modules\Core\Product\Services\ProductService`
- `Modules\Core\Catalog\ProductFilters` → `Modules\Core\Product\DTOs\ProductFilters`
@@ -380,6 +873,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- `Modules\Core\Search\ProductSearchService` → `Modules\Core\Product\Services\ProductSearchService`
Consuming apps must update any direct references — notably `config/lunar/search.php`'s `'indexers'` map, which points at `ProductIndexer` by FQCN. `Modules\Core\Catalog\ProductOptionTypeInterface` (in-progress, not yet wired to anything) was deliberately left in place rather than moved.
- Reorganized `Modules\Core\Localization` under the same stricter per-concern subfolder convention — `Events/`, `Filament/`, `Listeners/` were already correctly categorized; four loose root files moved into typed buckets by structural role:
- `Modules\Core\Localization\LocaleMiddleware` → `Modules\Core\Localization\Middleware\LocaleMiddleware`
- `Modules\Core\Localization\LanguageCacheObserver` → `Modules\Core\Localization\Observers\LanguageCacheObserver`
@@ -391,26 +885,31 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [0.5.4] - 2026-08-26
### Added
- `Modules\Core\Catalog\ProductService::list()` accepts a `sort` parameter (new `ProductSort` enum: `PriceAsc`, `PriceDesc`, `Newest`), translated into a Meilisearch `sort` clause — `list()` previously had no way to order results, since it always searches with an empty query string and so has no relevance score to fall back on. `Modules\Core\Search\ProductIndexer::getSortableFields()` now also marks `price` sortable (Lunar's base indexer only marks `created_at`/`updated_at`/`skus`/`status`). Requires re-syncing index settings (`php artisan lunar:meilisearch:setup`) on existing stores. Documented in `docs/product-listing.md` ("Sorting").
## [0.5.3] - 2026-08-26
### Fixed
- `Modules\Core\Search\ProductIndexer::toSearchableArray()` threw `column reference "id" is ambiguous` on Postgres when computing `channel_ids` — `$model->channels()->wherePivot('enabled', true)->pluck('id')` joins `lunar_channels` and `lunar_channelables`, both of which have an `id` column, and the unqualified `pluck('id')` left Postgres unable to resolve which table's column to select (SQLite/MySQL tolerated the ambiguity). Qualified as `pluck('lunar_channels.id')`.
## [0.5.2] - 2026-08-26
### Fixed
- `Modules\Core\Localization\LocaleMiddleware`'s shared view data only ever surfaced a single alternate locale (`altLocale`/`altLocaleUrl`, found via `firstWhere('code', '!=', $current)`) — correct by coincidence for a 2-language store, but silently dropped every locale past the first "other" one found for a 3+ language store, with no error. Replaced with `altLocales`, a collection of every other configured language (`code`, `name`, `url` for the current route each), so a language switcher or `hreflang` tags scale to any number of locales. Documented in `docs/localization.md` ("Shared view data — language switcher and `hreflang` tags").
## [0.5.1] - 2026-08-25
### Added
- `Modules\Core\Search\ProductIndexer` now indexes `channel_ids` (filterable) — Lunar's base indexer only marks `status` as filterable, not channel assignment, so storefront search couldn't otherwise scope results to products actually assigned and enabled on the current sales channel. Computed from `$product->channels()->wherePivot('enabled', true)`. Ported from an older `Products` branch whose remote had been deleted; the branch's other, now-superseded `ProductIndexer` changes were dropped in favor of the richer indexer already on `master` (collections, price, variants, reviews — see `0.5.0`).
## [0.5.0] - 2026-08-24
### Added
- **`Modules\Core\Catalog\ProductService`**: storefront product listing/filtering (`list()`) and single-product lookup (`getById()`, `getBySlug()`), reading directly from the Meilisearch index rather than the database — one data source, no `->get()` model hydration. Returns plain arrays (not Eloquent models), meant to be called directly from a consuming app's controllers.
- `ProductFilters` DTO: optional `collectionId`, `brand`, `minPrice`, `maxPrice`, translated into a Meilisearch `filter` expression.
- Listing results are locale-aware: `withLocalizedFields()` resolves `name`/`description` from the indexer's per-locale fields, falling back to the store's default language (via `LocaleMiddleware::defaultLocale()`) when the current locale has no translation yet, instead of rendering blank.
@@ -421,26 +920,29 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- `docs/lunar.md` "Gotchas": three new entries hit while building this — `ProductOption`/`ProductOptionValue::name` isn't `attribute_data` (so `translateAttribute()` silently returns `null` for it), a running `queue:work` process not picking up an edited Scout indexer class, and Scout's `paginateRaw()->items()` on the Meilisearch driver returning the whole raw response rather than a hit list.
### Fixed
- The admin login form (`Modules\Core\Auth\Filament\Pages\Login`) had no way back from the OTP-entry step to the email step short of reloading the page. A `back()` method resets to the email step; a "← Back" link/button is shown on the OTP step only.
## [0.4.0] - 2026-08-06
### Added
- **Locale-prefixed routing** (`Modules\Core\Localization\LocaleMiddleware`): a `locale` route-middleware alias, opt-in per shop (not pushed onto the `web` group globally, since admin/Livewire/webhook routes must not be locale-redirected). Reads the first URL segment against Lunar's own `languages` table, sets `App::setLocale()`, and redirects unprefixed/unknown-locale requests to a resolved locale (`Accept-Language` match → default language → first language). Every locale is prefixed, including the default (`/el/...`, `/en/...`), never a bare root — avoids the hreflang/duplicate-content ambiguity of a bare-root default locale.
- Language list cached with `Cache::rememberForever()`, invalidated via `Modules\Core\Localization\LanguageCacheObserver` dispatching `LanguageCreated`/`LanguageUpdated`/`LanguageDeleted` events (see below) rather than doing the work itself.
- **Language rename safety**: renaming a `Language::code` (e.g. `el` → `gr`) no longer strands existing translations. `MigrateTranslationsForRenamedLanguage` (listening on `LanguageUpdated`) migrates every affected `LanguageLine.text` key from the old code to the new one and flushes both codes' translation caches — closing a real data-loss gap where a rename would otherwise make existing `LanguageLine` translations permanently unreachable.
- **Storefront UI label translations**: pulled in `spatie/laravel-translation-loader` (self-registers via Composer package auto-discovery; its loader *extends* Laravel's file-based `FileLoader` and merges DB translations on top — existing Filament/Lunar vendor `lang/` strings are unaffected). Labels are looked up via Laravel's native `__('storefront.nav.cart')`, kept in its own `storefront` group so nothing collides with Lunar/Filament's own translation groups.
- **Storefront UI label translations**: pulled in `spatie/laravel-translation-loader` (self-registers via Composer package auto-discovery; its loader _extends_ Laravel's file-based `FileLoader` and merges DB translations on top — existing Filament/Lunar vendor `lang/` strings are unaffected). Labels are looked up via Laravel's native `__('storefront.nav.cart')`, kept in its own `storefront` group so nothing collides with Lunar/Filament's own translation groups.
- `Modules\Core\Command\InstallLunarCommand` (overriding `lunar:install`) seeds a starter set of ~15 common e-shop labels (`nav.*`, `cart.*`, `product.*`, `auth.*`, `search.*`, English + Greek), idempotently guarded so it's safe on every boot.
- `Modules\Core\Localization\TranslationReader::group('storefront')` returns the whole reduced/cached label array for a locale (backed by `LanguageLine`'s own forever-cache) — for sharing to a view as `$labels` or `@json()`-ing to JS, on top of `__()` for single-key Blade lookups.
- **Admin UI**: `Modules\Core\Localization\Filament\Resources\LanguageLineResource` (registered in `CorePlugin`) lists/searches/filters `language_lines` and edits each row's `group`, `key`, and one text input per locale currently in `lunar_languages` — locale columns/inputs are generated dynamically from the language list, so a new language needs no resource changes.
- **Event-driven writes**: `Modules\Core\Localization\TranslationService` (`create`/`update`/`delete`) is the single write path for `LanguageLine` — the Filament resource's Create/Edit/Delete pages route through it rather than Filament's default direct-model writes. Dispatches `TranslationCreated`/`TranslationUpdated` (carries the full pre-update `{group, key, text}` snapshot, so a bare rename is tracked the same as a text edit)/`TranslationDeleted`, each handled by two listeners:
- `FlushTranslationCache` — closes a real gap in `LanguageLine`'s own self-invalidation, which only flushes locales/groups present *after* a save. Flushes the union of old and new group+locale combinations, so a locale removed from `text`, or a `group`/`key` rename, can't leave a stale cached array behind.
- `FlushTranslationCache` — closes a real gap in `LanguageLine`'s own self-invalidation, which only flushes locales/groups present _after_ a save. Flushes the union of old and new group+locale combinations, so a locale removed from `text`, or a `group`/`key` rename, can't leave a stale cached array behind.
- `LogTranslationActivity` — audits every write via the existing `Modules\Core\Logging\ActivityLogService` (`lunar` activity log channel), same `created`/`updated`/`deleted` shape as every other domain write in this project. Properties are flattened with `Arr::dot()` before logging (`text.en`, `text.el` instead of a nested `text` object) since Filament's Activity resource renders `properties` with a flat `KeyValue` field that can't display nested arrays.
- `Modules\Core\Providers\LocalizationServiceProvider` — split out of the growing `CoreServiceProvider` (per this project's own "split when a provider does too much" convention) to own all locale/translation middleware, observer, and event-listener registration.
## [0.3.0] - 2026-07-12
### Added
- **Meilisearch product search**: pulled in `lunarphp/search` (Lunar's driver-agnostic search abstraction — `database`/`meilisearch`/`typesense` engines, selectable via Scout's own `SCOUT_DRIVER` config) and `lunarphp/meilisearch`, wiring Meilisearch in as the search engine for products.
- `Search\ProductIndexer` overrides Lunar's own indexer to strip HTML tags from string fields (e.g. `name_en`, `description_en`) before they reach the search index — Lunar's default indexer sends raw attribute HTML straight through, which pollutes relevance ranking and highlighting with markup.
- Meilisearch itself is treated as app-level infrastructure, not a `boboko-core` concern: the actual Meilisearch container, host port, and master key live in each consuming app's own `docker-compose.yml`/`.env` (e.g. `3dealer`), the same way Postgres and Valkey do — `boboko-core` only declares the PHP package dependency and the indexing code.
@@ -448,17 +950,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [0.2.0] - 2026-07-10
### Added
- **Product reviews** (`Modules\Core\Review`): a new `ProductReview` model + `product_reviews` table (plain, unprefixed — same convention as `import_mappings`), linked to Lunar's `Product` via a `Product::reviews()` macro (registered in `CorePlugin`, since `Lunar\Models\Product` is a vendor model and can't be edited directly).
- **JudgeMe CSV review importer** (`MigrateImport\JudgeMe\JudgeMeExportImporter`), wired into the existing `boboko:migrate:import --source=judgeme --type=export` command: reads a Judge.me review export, resolves each row's `product_handle` to a Lunar product via `Lunar\Models\Url`, and creates/updates `ProductReview` rows idempotently via `import_mappings` (`source=judgeme`, `source_type=review`, keyed on Judge.me's `metaobject_handle`). Rows with no matching product are skipped with a logged warning rather than failing the whole import.
- Review images (`picture_urls` in the CSV) are downloaded and stored as real media via Spatie MediaLibrary (`ProductReview::IMAGES_COLLECTION`), not just linked by URL — consistent with how product images are handled.
- **Admin UI**: a new "Reviews" sub-navigation page on the product edit screen (`Review\Pages\ManageProductReviews`, wired via `Review\Extensions\ProductResourceExtension`), listing rating/title/reviewer with View, Reply, and Delete actions. The Reply action lets staff write/edit a reply directly from the table, setting `replied_at`. The View modal shows full review detail (body, reviewer email, location, source, dates, reply, downloaded images).
### Fixed
- `Shopify\ShopifyExportImporter` never wrote a Lunar `Url` (slug) row for imported products, despite `docs/shopify-import.md` specifying it should — meaning no code outside the importer itself could resolve "which Lunar product has handle X" (only the importer's own private `import_mappings` bookkeeping could). It now creates/updates a default `Url` row (`slug` = Shopify handle) per product on every import, which the new JudgeMe review importer depends on for product resolution.
## [0.1.0] - 2026-07-09
### Added
- **Shipping**: registered Lunar's `lunarphp/table-rate-shipping` plugin (`ShippingPlugin`) directly on `CorePlugin`, so table-rate shipping is available to every consumer app without per-app wiring.
- **Product migration/import framework** (`Modules\Core\MigrateImport`): a source-agnostic pipeline for importing a vendor's product catalog into Lunar.
- `boboko:migrate:import` Artisan command — interactively prompts for source, type (export/API), and credentials or file path, then dispatches the import as a queued job (`RunMigrateImportJob`) on the default queue. The file-path prompt resolves relative to `storage/app/private/imports/`, so answering e.g. `shopify` picks up the first CSV found in `imports/shopify/` automatically.
@@ -473,6 +978,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- `CONTRIBUTE.md` — local dev setup (path-repo + `bin/dc-core.sh`), and the manual DB-verification workflow used to build this feature.
### Fixed
- `ProductOptionResolver` created duplicate `ProductOption`/`ProductOptionValue` rows when the same option or value appeared with different casing across products (e.g. Shopify export rows using both "Size" and "size"), and could create a duplicate value within a single product's own variant rows due to relying on a stale lazy-loaded relation. Both now resolve by normalized (slugified) identity queried fresh from the database.
- `boboko:migrate:import` could dispatch an import job with a blank file path (silent no-op failure) if the file-path prompt was answered empty; it now re-prompts until a valid, existing file is given.
@@ -481,6 +987,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
First release.
### Added
- OTP-based authentication built around `User` instead of `Customer` (`UserOtpService`, `UserOtpMail`), replacing the earlier customer-scoped OTP flow.
- `UserCreated` event with a `CreateCustomerForUser` listener to provision a Lunar customer automatically when a user is created.
- `UserRelationManager` for managing users from the customer resource in the panel.
@@ -491,7 +998,9 @@ First release.
- `docs/modules.md` documenting module structure.
### Removed
- `CustomerOtpMail` and `CustomerOtpService`, superseded by the user-based OTP flow.
### Dependencies
- Added explicit `symfony/yaml` requirement (used directly by `Stoic::loadConfig()`).
+2 -2
View File
@@ -2,7 +2,7 @@
"name": "boboko/core",
"description": "Core module — authentication and shared panel behaviour",
"type": "library",
"version": "0.16.1",
"version": "0.18.0",
"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",
+58
View File
@@ -30,6 +30,64 @@ 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,
],
/*
|--------------------------------------------------------------------------
| Storefront OTP Login
|--------------------------------------------------------------------------
|
| Modules\Core\Auth\Services\UserOtpService's passwordless login.
| max_attempts caps how many wrong codes a shopper can guess against ONE
| generated code before it's invalidated outright. generation_limit/
| generation_decay_minutes cap how often a NEW code can be requested for
| the same email — independent of max_attempts, since generating a fresh
| code also resets the guess count, so an attempt cap alone doesn't stop
| an attacker from just requesting a new code every few tries. This same
| limit is also what stands between a malicious/careless caller and
| mail-bombing one inbox.
|
*/
'auth' => [
'otp' => [
'max_attempts' => 5,
'generation_limit' => 3,
'generation_decay_minutes' => 10,
],
],
];
+4 -4
View File
@@ -1,6 +1,6 @@
<?php
use Modules\Core\Payment\Pipelines\Cart\ApplyCashOnDeliveryFee;
use Modules\Core\Payment\Pipelines\Cart\ApplyPaymentMethodFee;
return [
/*
@@ -9,8 +9,8 @@ return [
|--------------------------------------------------------------------------
|
| 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
@@ -23,6 +23,6 @@ return [
|
*/
'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,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');
});
}
};
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Caps brute-forcing a 6-digit OTP code (1M combinations, 10-minute
* window, previously uncapped) — see Modules\Core\Auth\Services\
* UserOtpService::validate(), which now invalidates the code entirely
* (forcing a fresh generateAndSend()) once otp_attempts reaches its max,
* rather than leaving a live code guessable indefinitely within its
* expiry window.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->unsignedTinyInteger('otp_attempts')->default(0)->after('otp_expires_at');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('otp_attempts');
});
}
};
@@ -0,0 +1,41 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* A per-login session registry, independent of the actual session store
* driver (SESSION_DRIVER=redis in this app — no "sessions" table to
* purge by user_id the way the database driver would allow). Each
* successful OTP login (Modules\Core\Auth\Services\UserOtpService::
* validate()) records one row here and stamps the token into the
* Laravel session payload; Modules\Core\Auth\Http\Middleware\
* EnsureSessionNotRevoked checks it on every request. "Logout
* everywhere" (Modules\Core\Auth\Services\UserSessionService::
* revokeOtherSessions()) is then just marking every OTHER row
* revoked_at, no session-store-specific logic anywhere.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('user_sessions', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('token', 64)->unique();
$table->string('user_agent')->nullable();
$table->string('ip_address', 45)->nullable();
$table->timestamp('last_used_at');
$table->timestamp('revoked_at')->nullable();
$table->timestamps();
$table->index(['user_id', 'revoked_at']);
});
}
public function down(): void
{
Schema::dropIfExists('user_sessions');
}
};
@@ -0,0 +1,65 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Lunar\Models\Language;
/**
* PaymentMethod.name becomes a locale-keyed JSON array (e.g.
* {"en": "Cash On Delivery", "el": "Αντικαταβολή"}), rendered in Filament
* via Lunar's own Lunar\Admin\Support\Forms\Components\TranslatedText —
* the same reusable component/data-shape Product/Collection names already
* use (Lunar\Base\Traits\HasTranslations), just applied directly to a
* plain column here rather than through attribute_data, since
* PaymentMethod is a merchant-configured settings row, not a translatable
* catalog attribute.
*
* Existing plain-string rows are preserved under the store's default
* Language code (falls back to 'en' if no Language row exists yet — this
* migration can run before lunar:install seeds one) rather than dropped,
* so an already-configured payment method's name isn't blanked out.
*
* Uses a raw `ALTER COLUMN ... TYPE` rather than Blueprint::change()
* (which requires doctrine/dbal — not installed in this project) —
* Postgres-specific (this project runs on `pgsql`, per its own docker
* setup), with an explicit USING clause since json isn't implicitly
* castable from varchar.
*/
return new class extends Migration
{
public function up(): void
{
$defaultLocale = Language::where('default', true)->value('code') ?? 'en';
$existing = DB::table('payment_methods')->pluck('name', 'id');
DB::statement('ALTER TABLE payment_methods ALTER COLUMN name DROP DEFAULT');
DB::statement("ALTER TABLE payment_methods ALTER COLUMN name TYPE json USING NULL");
foreach ($existing as $id => $name) {
if ($name === null) {
continue;
}
DB::table('payment_methods')
->where('id', $id)
->update(['name' => json_encode([$defaultLocale => $name])]);
}
}
public function down(): void
{
$defaultLocale = Language::where('default', true)->value('code') ?? 'en';
$existing = DB::table('payment_methods')->pluck('name', 'id');
DB::statement('ALTER TABLE payment_methods ALTER COLUMN name TYPE varchar(255) USING NULL');
foreach ($existing as $id => $name) {
$decoded = json_decode((string) $name, true);
$flat = is_array($decoded) ? ($decoded[$defaultLocale] ?? reset($decoded) ?: null) : $name;
DB::table('payment_methods')->where('id', $id)->update(['name' => $flat]);
}
}
};
@@ -0,0 +1,74 @@
<?php
use Illuminate\Support\Facades\DB;
use Lunar\Base\Migration;
use Lunar\Models\Language;
/**
* ShippingMethod.name becomes a locale-keyed JSON array (e.g.
* {"en": "Standard Delivery", "el": "Κανονική Παράδοση"}), rendered in
* Filament via Lunar's own Lunar\Admin\Support\Forms\Components\
* TranslatedText (Modules\Core\Shipping\Extensions\
* ShippingMethodResourceExtension::replaceNameField()) — same shape/
* resolution as PaymentMethod.name (see its own migration,
* 2026_09_15_000001_make_payment_methods_name_translatable.php) and
* Product/Collection names (Lunar\Base\Traits\HasTranslations).
*
* ShippingMethod is a vendor (lunarphp/table-rate-shipping) table, but
* converting a vendor column's type via a migration is no different from
* any other schema change this project already makes against a vendor
* table (see database/migrations/2026_08_31_000001_create_payment_methods_table.php's
* sibling migrations for the same pattern against PaymentMethod) — there
* was no good reason to route this through `data.name` instead, unlike
* `data.fulfillment_type` which is a genuinely NEW field the vendor table
* never had at all.
*
* Existing plain-string rows are preserved under the store's default
* Language code (falls back to 'en' if no Language row exists yet)
* rather than dropped.
*
* Uses a raw `ALTER COLUMN ... TYPE` rather than Blueprint::change()
* (requires doctrine/dbal — not installed in this project) — Postgres-
* specific (this project runs on `pgsql`), with an explicit USING clause
* since json isn't implicitly castable from varchar.
*/
return new class extends Migration
{
public function up(): void
{
$table = $this->prefix.'shipping_methods';
$defaultLocale = Language::where('default', true)->value('code') ?? 'en';
// The column is NOT NULL (vendor migration never marked it
// nullable) — converting via `USING NULL` first, then
// backfilling with a second UPDATE, violates that constraint
// before the backfill ever runs. json_build_object() converts
// each existing string in place, in the same statement, so the
// column is never transiently NULL. $defaultLocale is inlined
// (not bound) — parameter binding inside an ALTER TABLE ... USING
// expression isn't reliable across drivers; it's a Language::code
// value we control, not user input, so quote_literal-safe
// interpolation here is fine.
$quotedLocale = DB::getPdo()->quote($defaultLocale);
DB::statement("ALTER TABLE {$table} ALTER COLUMN name TYPE json USING json_build_object({$quotedLocale}, name)");
}
public function down(): void
{
$table = $this->prefix.'shipping_methods';
$defaultLocale = Language::where('default', true)->value('code') ?? 'en';
// Same NOT NULL constraint applies going back — ->>'{locale}'
// extracts the default locale's text value directly in the
// USING clause, falling back to the first key present via
// COALESCE for any row missing that locale (e.g. one only ever
// filled in via a non-default language).
$quotedLocale = DB::getPdo()->quote($defaultLocale);
DB::statement(
"ALTER TABLE {$table} ALTER COLUMN name TYPE varchar(255) ".
"USING COALESCE(name->>{$quotedLocale}, (SELECT value FROM json_each_text(name) LIMIT 1))"
);
}
};
+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
+21
View File
@@ -0,0 +1,21 @@
<?php
/**
* Greek translations for Lunar\Models\Country::name, keyed by the exact
* English spelling Lunar's own installer seeds (`lunar:import:address-data`
* fetches http://data.lunarphp.io/countries+states.json — see
* vendor/lunarphp/core/src/Console/Commands/Import/AddressData.php).
* `Country`/`State` have no i18n support of their own (plain string
* columns, no translatable trait) — this is a plain Laravel lang file, not
* Modules\Core\Localization's DB-backed TranslationService, since these
* names are fixed reference data seeded once, not editable UI copy (see
* docs/localization.md). A consuming app's storefront looks this up
* itself, e.g. __('core::countries.'.$country->name) — core has no
* storefront UI of its own to wire this into (see docs/lunar.md).
*
* Only Greece is covered — this store operates within Greece; add further
* countries here as needed.
*/
return [
'Greece' => 'Ελλάδα',
];
+52
View File
@@ -0,0 +1,52 @@
<?php
/**
* Greek translations for Lunar\Models\State::name, keyed by the exact
* English spelling Lunar's own installer seeds for Greece
* (`lunar:import:address-data` — see lang/el/countries.php's own docblock
* for the full explanation of why this is a plain lang file, not
* Modules\Core\Localization's TranslationService).
*
* Covers every Greek state/regional-unit row in Lunar's seed dataset —
* scoped to Greece only, matching this store's operating country.
*/
return [
'Achaea Regional Unit' => 'Περιφερειακή Ενότητα Αχαΐας',
'Aetolia-Acarnania Regional Unit' => 'Περιφερειακή Ενότητα Αιτωλοακαρνανίας',
'Arcadia Prefecture' => 'Νομός Αρκαδίας',
'Argolis Regional Unit' => 'Περιφερειακή Ενότητα Αργολίδας',
'Attica Region' => 'Περιφέρεια Αττικής',
'Boeotia Regional Unit' => 'Περιφερειακή Ενότητα Βοιωτίας',
'Central Greece Region' => 'Περιφέρεια Στερεάς Ελλάδας',
'Central Macedonia' => 'Κεντρική Μακεδονία',
'Chania Regional Unit' => 'Περιφερειακή Ενότητα Χανίων',
'Corfu Prefecture' => 'Νομός Κέρκυρας',
'Corinthia Regional Unit' => 'Περιφερειακή Ενότητα Κορινθίας',
'Crete Region' => 'Περιφέρεια Κρήτης',
'Drama Regional Unit' => 'Περιφερειακή Ενότητα Δράμας',
'East Attica Regional Unit' => 'Περιφερειακή Ενότητα Ανατολικής Αττικής',
'East Macedonia and Thrace' => 'Ανατολική Μακεδονία και Θράκη',
'Epirus Region' => 'Περιφέρεια Ηπείρου',
'Euboea' => 'Εύβοια',
'Grevena Prefecture' => 'Νομός Γρεβενών',
'Imathia Regional Unit' => 'Περιφερειακή Ενότητα Ημαθίας',
'Ioannina Regional Unit' => 'Περιφερειακή Ενότητα Ιωαννίνων',
'Ionian Islands Region' => 'Περιφέρεια Ιονίων Νήσων',
'Karditsa Regional Unit' => 'Περιφερειακή Ενότητα Καρδίτσας',
'Kastoria Regional Unit' => 'Περιφερειακή Ενότητα Καστοριάς',
'Kefalonia Prefecture' => 'Νομός Κεφαλληνίας',
'Kilkis Regional Unit' => 'Περιφερειακή Ενότητα Κιλκίς',
'Kozani Prefecture' => 'Νομός Κοζάνης',
'Laconia' => 'Λακωνία',
'Larissa Prefecture' => 'Νομός Λάρισας',
'Lefkada Regional Unit' => 'Περιφερειακή Ενότητα Λευκάδας',
'Pella Regional Unit' => 'Περιφερειακή Ενότητα Πέλλας',
'Peloponnese Region' => 'Περιφέρεια Πελοποννήσου',
'Phthiotis Prefecture' => 'Νομός Φθιώτιδας',
'Preveza Prefecture' => 'Νομός Πρέβεζας',
'Serres Prefecture' => 'Νομός Σερρών',
'South Aegean' => 'Νότιο Αιγαίο',
'Thessaloniki Regional Unit' => 'Περιφερειακή Ενότητα Θεσσαλονίκης',
'West Greece Region' => 'Περιφέρεια Δυτικής Ελλάδας',
'West Macedonia Region' => 'Περιφέρεια Δυτικής Μακεδονίας',
];
@@ -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>
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace Modules\Core\Auth\Events;
use Illuminate\Contracts\Auth\Authenticatable;
/**
* Dispatched by Modules\Core\Auth\Services\UserOtpService::validate() on a
* successful OTP login — distinct from UserCreated (which only fires for
* a genuinely first-time email); this fires on every successful login,
* new user or returning one.
*/
class CustomerLoggedIn
{
public function __construct(
public readonly Authenticatable $user,
) {}
}
@@ -0,0 +1,20 @@
<?php
namespace Modules\Core\Auth\Exceptions;
use RuntimeException;
/**
* Thrown by Modules\Core\Auth\Services\UserOtpService::generateAndSend()
* when an email has requested too many codes too quickly — caps both
* mail-bombing one inbox and the "just request a fresh code to reset my
* guess count" loophole a per-code attempt cap alone doesn't close.
*/
class OtpThrottledException extends RuntimeException
{
public function __construct(
public readonly int $availableInSeconds,
) {
parent::__construct("Too many code requests. Try again in {$availableInSeconds} second(s).");
}
}
@@ -0,0 +1,50 @@
<?php
namespace Modules\Core\Auth\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Modules\Core\Auth\Services\UserSessionService;
use Symfony\Component\HttpFoundation\Response;
/**
* The enforcement half of the session registry — see
* Modules\Core\Auth\Services\UserSessionService's own docblock. Not
* auto-registered anywhere (no routes/kernel wiring exist in this
* package — see Modules\Core\Customer\Services\CustomerAccountService's
* own docblock for why this branch stops at services); a consuming app
* adds this to its `web` middleware group (after `auth`) to actually get
* "logout everywhere" enforcement.
*
* A request with no recorded UserSession at all (see
* UserSessionService::currentSession()'s own docblock) is let through —
* only an EXPLICITLY revoked session is rejected.
*/
class EnsureSessionNotRevoked
{
public function __construct(
private readonly UserSessionService $sessions,
) {}
public function handle(Request $request, Closure $next): Response
{
if (! Auth::check()) {
return $next($request);
}
$session = $this->sessions->currentSession();
if ($session && $session->isRevoked()) {
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
abort(401, 'Your session has been revoked. Please log in again.');
}
$session?->update(['last_used_at' => now()]);
return $next($request);
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace Modules\Core\Auth\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* One row per login (see Modules\Core\Auth\Services\UserOtpService::
* validate()) — see that table's own migration docblock for why this
* exists independent of the actual session-store driver.
*/
class UserSession extends Model
{
protected $guarded = [];
protected $casts = [
'last_used_at' => 'datetime',
'revoked_at' => 'datetime',
];
public function user(): BelongsTo
{
$model = config('auth.providers.users.model');
return $this->belongsTo($model);
}
public function isRevoked(): bool
{
return $this->revoked_at !== null;
}
}
+118 -10
View File
@@ -2,16 +2,76 @@
namespace Modules\Core\Auth\Services;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\RateLimiter;
use Modules\Core\Auth\Events\CustomerLoggedIn;
use Modules\Core\Auth\Exceptions\OtpThrottledException;
use Modules\Core\Auth\Mail\UserOtpMail;
/**
* The storefront's passwordless login — a shopper supplies only an email
* (Shopify-style), gets a 6-digit code, and validate() authenticates the
* `web` guard via Auth::login().
*
* That alone is enough to merge/associate any active guest cart into the
* now-known customer — Auth::login() fires Illuminate\Auth\Events\Login,
* which Lunar's own Lunar\Listeners\CartSessionAuthListener (registered
* unconditionally in LunarServiceProvider::boot(), no opt-in needed)
* already listens to, calling CartSession::associate() with
* config('lunar.cart.auth_policy') — 'merge' by default, 'override' if a
* consumer changes that config. Deliberately no cart-association call
* here: doing our own on top would run a SECOND merge attempt with a
* hardcoded policy that ignores whatever the consumer configured.
*
* generateAndSend()'s find-or-create already triggers the full
* Customer/User pairing cascade for a genuinely new email — see
* Modules\Core\Auth\Events\UserCreated's own docblock and
* Modules\Core\Customer\Listeners\CreateCustomerForUser.
*
* Two independent throttles, both configured under core.auth.otp — see
* config/core.php's own comment for why they're separate: max_attempts
* caps wrong guesses against ONE code; generation_limit caps how often a
* NEW code can be requested for the same email at all (closes both the
* "regenerate to reset my guess count" loophole and mail-bombing one
* inbox).
*
* validate() also records a UserSessionService entry for the new login —
* see that class's own docblock for the "logout everywhere" registry
* this feeds (Modules\Core\Auth\Http\Middleware\EnsureSessionNotRevoked
* is the enforcement half; a consuming app must add it to its own
* middleware stack). $request is optional purely so this service stays
* callable from a context with no HTTP request at all (a console
* command, a test) — user-agent/ip are simply not recorded when omitted.
*/
class UserOtpService
{
private const EXPIRY_MINUTES = 10;
private const CODE_LENGTH = 6;
public function __construct(
private readonly UserSessionService $sessions,
) {}
/**
* @throws OtpThrottledException if this email has requested too many
* codes within core.auth.otp.generation_decay_minutes
*/
public function generateAndSend(string $email): bool
{
$limiterKey = $this->generationLimiterKey($email);
$maxGenerations = (int) config('core.auth.otp.generation_limit', 3);
if (RateLimiter::tooManyAttempts($limiterKey, $maxGenerations)) {
throw new OtpThrottledException(RateLimiter::availableIn($limiterKey));
}
RateLimiter::hit($limiterKey, (int) config('core.auth.otp.generation_decay_minutes', 10) * 60);
$model = config('auth.providers.users.model');
$user = $model::firstOrCreate(['email' => $email]);
@@ -19,6 +79,7 @@ class UserOtpService
$user->otp_code = $code;
$user->otp_expires_at = now()->addMinutes(self::EXPIRY_MINUTES);
$user->otp_attempts = 0;
$user->save();
Mail::to($user->email)->send(new UserOtpMail($user->name ?? $user->email, $code));
@@ -26,23 +87,70 @@ class UserOtpService
return true;
}
public function validate(string $email, string $code)
/**
* A wrong code counts against core.auth.otp.max_attempts and, once
* reached, invalidates the code entirely — the shopper must request
* a fresh one via generateAndSend() (itself throttled independently
* — see this class's own docblock) rather than being able to keep
* guessing against a still-live code for the rest of its 10-minute
* expiry window.
*/
public function validate(string $email, string $code, ?Request $request = null): ?Authenticatable
{
$model = config('auth.providers.users.model');
$user = $model::where('email', $email)->first();
if (! $user) {
// lockForUpdate() + a transaction make the read-check-increment-save
// below atomic across concurrent requests for the same user — without
// it, two guesses fired in parallel can each read the same
// pre-increment otp_attempts value and both save past
// max_attempts, letting an attacker exceed the lockout by
// parallelizing requests instead of sending them serially.
$result = DB::transaction(function () use ($model, $email, $code) {
$user = $model::where('email', $email)->lockForUpdate()->first();
if (! $user || ! $user->otp_expires_at || now()->isAfter($user->otp_expires_at)) {
return null;
}
if (! hash_equals((string) $user->otp_code, $code)) {
$user->otp_attempts++;
if ($user->otp_attempts >= (int) config('core.auth.otp.max_attempts', 5)) {
$user->otp_code = null;
$user->otp_expires_at = null;
$user->otp_attempts = 0;
}
$user->save();
return null;
}
$user->otp_code = null;
$user->otp_expires_at = null;
$user->otp_attempts = 0;
$user->save();
return $user;
});
if (! $result) {
return null;
}
if (! $user->otp_expires_at || $user->otp_code != $code || now()->isAfter($user->otp_expires_at)) {
return null;
}
RateLimiter::clear($this->generationLimiterKey($email));
$user->otp_code = null;
$user->otp_expires_at = null;
$user->save();
Auth::login($result);
return $user;
$this->sessions->record($result, $request);
Event::dispatch(new CustomerLoggedIn($result));
return $result;
}
private function generationLimiterKey(string $email): string
{
return 'otp-generate:'.strtolower($email);
}
}
+105
View File
@@ -0,0 +1,105 @@
<?php
namespace Modules\Core\Auth\Services;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Modules\Core\Auth\Models\UserSession;
/**
* The record/revoke half of the session registry — see
* database/migrations/2026_09_15_000001_create_user_sessions_table.php's
* own docblock for why this exists (SESSION_DRIVER=redis in this app has
* no "sessions" table to purge by user_id). The enforcement half is
* Modules\Core\Auth\Http\Middleware\EnsureSessionNotRevoked, which reads
* the token this class stamps into the session payload.
*/
class UserSessionService
{
private const SESSION_TOKEN_KEY = 'user_session_token';
/**
* Called once, right after Auth::login() succeeds (see
* UserOtpService::validate()) — generates a fresh token, records it,
* and stamps it into the CURRENT session payload so
* EnsureSessionNotRevoked can look it up on later requests.
*/
public function record(Authenticatable $user, ?Request $request = null): UserSession
{
$token = Str::random(64);
$session = UserSession::create([
'user_id' => $user->getAuthIdentifier(),
'token' => $token,
'user_agent' => $request?->userAgent(),
'ip_address' => $request?->ip(),
'last_used_at' => now(),
]);
session([self::SESSION_TOKEN_KEY => $token]);
return $session;
}
/**
* Revokes every OTHER active session for $user — the current one
* (matched by the token in the CURRENT session payload) is left
* alone, matching Laravel's own logoutOtherDevices() semantics
* (there just isn't a password to re-verify against here — this is a
* passwordless account, so revocation is simply "every row that
* isn't the one making this request").
*
* Known, deliberately accepted gap: this requires only a currently
* valid session, not a freshly-completed login — so anyone holding
* an already-authenticated session (e.g. someone who sits down at an
* account left logged in on a shared/public PC) can use this to
* evict the real owner's OTHER sessions just as easily as the real
* owner could use it to evict an intruder's. A stricter version would
* require a fresh OTP re-verification (e.g. within the last few
* minutes) before allowing this call. Left as-is for now — revisit if
* this turns out to matter in practice, rather than building
* abuse-resistance against a threat model nobody's confirmed is real
* for this storefront.
*/
public function revokeOtherSessions(Authenticatable $user): int
{
$currentToken = session(self::SESSION_TOKEN_KEY);
return UserSession::query()
->where('user_id', $user->getAuthIdentifier())
->whereNull('revoked_at')
->when($currentToken, fn ($query) => $query->where('token', '!=', $currentToken))
->update(['revoked_at' => now()]);
}
/**
* Revokes EVERY session for $user, current one included — for a
* "this account may be compromised" response, not a routine logout.
*/
public function revokeAllSessions(Authenticatable $user): int
{
return UserSession::query()
->where('user_id', $user->getAuthIdentifier())
->whereNull('revoked_at')
->update(['revoked_at' => now()]);
}
/**
* @return UserSession|null null if the CURRENT session has no
* recorded token at all (e.g. a session predating this feature, or
* one Auth::login() established outside UserOtpService) — treated
* as valid by EnsureSessionNotRevoked rather than rejected, since
* there's nothing to have been revoked.
*/
public function currentSession(): ?UserSession
{
$token = session(self::SESSION_TOKEN_KEY);
if (! $token) {
return null;
}
return UserSession::where('token', $token)->first();
}
}
+12 -16
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;
@@ -37,7 +37,14 @@ use Modules\Core\Recovery\Events\CheckoutAbandoned;
* 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.
* 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
{
@@ -45,33 +52,22 @@ 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)
$lifecycle->abandonedCarts(Cart::query())
->where('meta->recovery_consent', true)
->with('lines')
->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) {
+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"
+20 -11
View File
@@ -173,19 +173,19 @@ class CheckoutService
/**
* 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
@@ -204,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();
@@ -289,6 +297,7 @@ class CheckoutService
$order->meta = [
...($order->meta?->toArray() ?? []),
'payment_method' => $type,
'terms_accepted' => true,
'terms_accepted_at' => now()->toIso8601String(),
'terms_accepted_policy_version' => $policyVersion,
@@ -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.');
}
}
+15 -3
View File
@@ -66,6 +66,16 @@ class InstallLunarCommand extends Command
]);
}
if (! Language::where('code', 'el')->exists()) {
$this->components->info('Adding Greek language');
Language::create([
'code' => 'el',
'name' => 'Greek',
'default' => false,
]);
}
if (! Currency::whereDefault(true)->exists()) {
$this->components->info('Adding a default currency (USD)');
@@ -310,10 +320,12 @@ class InstallLunarCommand extends Command
PaymentMethod::create([
'type' => 'cash-on-delivery',
'name' => 'Cash on Delivery',
'driver' => 'offline',
'name' => [
'en' => 'Cash on Delivery',
'el' => 'Αντικαταβολή',
],
'driver' => 'cash-on-delivery',
'capture_mode' => 'pay',
'captured_status' => 'payment-offline',
'position' => 0,
'enabled' => false,
'data' => [],
+9 -5
View File
@@ -27,15 +27,18 @@ 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\OrderRefundActionsExtension;
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
{
@@ -55,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,
@@ -66,7 +70,7 @@ class CorePlugin implements Plugin
ValuesRelationManager::class => ValuesRelationManagerExtension::class,
ShippingMethodResource::class => ShippingMethodResourceExtension::class,
ListShippingMethod::class => ShippingMethodListExtension::class,
ManageOrder::class => [OrderViewExtension::class, OrderRefundActionsExtension::class, OrderTransactionsExtension::class],
ManageOrder::class => [OrderViewExtension::class, OrderActionsExtension::class, OrderTransactionsExtension::class, OrderPaymentMethodSummaryExtension::class, OrderShipmentsExtension::class],
OrderItemsTable::class => OrderItemsTableExtension::class,
]);
@@ -0,0 +1,22 @@
<?php
namespace Modules\Core\Customer\Events;
use Illuminate\Contracts\Auth\Authenticatable;
use Lunar\Models\Address;
/**
* Dispatched by Modules\Core\Customer\Services\CustomerAccountService::
* createAddress(). $causer is carried explicitly (unlike e.g.
* Modules\Core\Payment\Events\PaymentMethodCreated, which is always
* staff-caused implicitly) because this write happens on the `web`
* guard, not `staff` — a listener logging this needs to know who to
* attribute it to without guessing a guard.
*/
class CustomerAddressCreated
{
public function __construct(
public readonly Address $address,
public readonly Authenticatable $causer,
) {}
}
@@ -0,0 +1,17 @@
<?php
namespace Modules\Core\Customer\Events;
use Illuminate\Contracts\Auth\Authenticatable;
class CustomerAddressDeleted
{
/**
* @param array<string, mixed> $address Snapshot of the deleted
* row — already gone from the database by dispatch time.
*/
public function __construct(
public readonly array $address,
public readonly Authenticatable $causer,
) {}
}
@@ -0,0 +1,19 @@
<?php
namespace Modules\Core\Customer\Events;
use Illuminate\Contracts\Auth\Authenticatable;
use Lunar\Models\Address;
class CustomerAddressUpdated
{
/**
* @param array<string, mixed> $old Snapshot of the changed
* attributes before the update.
*/
public function __construct(
public readonly Address $address,
public readonly array $old,
public readonly Authenticatable $causer,
) {}
}
@@ -0,0 +1,19 @@
<?php
namespace Modules\Core\Customer\Events;
use Illuminate\Contracts\Auth\Authenticatable;
use Modules\Core\Customer\Models\Customer;
class CustomerProfileUpdated
{
/**
* @param array<string, mixed> $old Snapshot of the changed
* attributes before the update.
*/
public function __construct(
public readonly Customer $customer,
public readonly array $old,
public readonly Authenticatable $causer,
) {}
}
@@ -0,0 +1,20 @@
<?php
namespace Modules\Core\Customer\Exceptions;
use RuntimeException;
/**
* Thrown by Modules\Core\Customer\Services\CustomerAccountService when an
* address id doesn't belong to the customer making the request — never
* a plain 404/ModelNotFoundException, so a storefront can't probe for
* another customer's address ids by trying sequential ones and reading
* the response shape.
*/
class AddressNotFoundException extends RuntimeException
{
public function __construct()
{
parent::__construct('Address not found.');
}
}
@@ -0,0 +1,19 @@
<?php
namespace Modules\Core\Customer\Exceptions;
use RuntimeException;
/**
* Thrown by Modules\Core\Customer\Services\CustomerAccountService when an
* order id doesn't belong to the customer making the request (or isn't
* placed yet) — never a plain 404/ModelNotFoundException, so a
* storefront can't probe for another customer's order ids.
*/
class OrderNotFoundException extends RuntimeException
{
public function __construct()
{
parent::__construct('Order not found.');
}
}
@@ -0,0 +1,61 @@
<?php
namespace Modules\Core\Customer\Listeners;
use Lunar\Models\Address;
use Modules\Core\Customer\Events\CustomerAddressCreated;
use Modules\Core\Customer\Events\CustomerAddressDeleted;
use Modules\Core\Customer\Events\CustomerAddressUpdated;
use Modules\Core\Customer\Events\CustomerProfileUpdated;
use Modules\Core\Logging\ActivityLogService;
/**
* Same pattern as Payment\Listeners\LogPaymentMethodActivity — routes
* Modules\Core\Customer\Services\CustomerAccountService's own events
* through the shared Logging\ActivityLogService, giving every
* shopper-initiated address/profile change an audit trail (previously
* none existed at all for account self-service writes). $causer is
* passed through explicitly on every call, since these events are
* `web`-guard-caused, not `staff`-guard — see ActivityLogService's own
* docblock for why that parameter exists.
*/
class LogCustomerAccountActivity
{
public function __construct(
private readonly ActivityLogService $activityLog,
) {}
public function handleAddressCreated(CustomerAddressCreated $event): void
{
$this->activityLog->created($event->address, $event->address->getAttributes(), $event->causer);
}
public function handleAddressUpdated(CustomerAddressUpdated $event): void
{
$this->activityLog->updated(
$event->address,
$event->old,
$event->address->only(array_keys($event->old)),
$event->causer,
);
}
public function handleAddressDeleted(CustomerAddressDeleted $event): void
{
$subject = (new Address)->forceFill($event->address);
$subject->exists = true;
$subject->id = $event->address['id'];
$this->activityLog->deleted($subject, $event->address, $event->causer);
}
public function handleProfileUpdated(CustomerProfileUpdated $event): void
{
$this->activityLog->updated(
$event->customer,
$event->old,
$event->customer->only(array_keys($event->old)),
$event->causer,
);
}
}
@@ -0,0 +1,255 @@
<?php
namespace Modules\Core\Customer\Services;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Event;
use Lunar\Models\Address;
use Lunar\Models\Order;
use LogicException;
use Modules\Core\Customer\Events\CustomerAddressCreated;
use Modules\Core\Customer\Events\CustomerAddressDeleted;
use Modules\Core\Customer\Events\CustomerAddressUpdated;
use Modules\Core\Customer\Events\CustomerProfileUpdated;
use Modules\Core\Customer\Exceptions\AddressNotFoundException;
use Modules\Core\Customer\Exceptions\OrderNotFoundException;
use Modules\Core\Customer\Models\Customer;
/**
* The storefront-facing "My Account" API — mirrors Modules\Core\Cart\
* Services\CartService's shape, one boboko-owned service a storefront
* calls, so Lunar's own Customer/Order/Address models stay an
* implementation detail. Every method is scoped to the given
* Authenticatable's own Customer::latestCustomer() (see docs/modules.md
* "Customer/User Pairing") — there is no method here that accepts a bare
* order/address id without also requiring the owning user, precisely so
* a controller built on top of this can't accidentally leak one
* customer's data to another by trusting a client-supplied id alone.
*
* $user->latestCustomer() can be null for a User that has no paired
* Customer yet (shouldn't happen via the normal OTP-login cascade — see
* Modules\Core\Auth\Events\UserCreated — but is defended against anyway,
* since nothing stops a User row existing without one, e.g. seeded data)
* — every method returns an empty/null result rather than throwing in
* that case, since "no customer paired yet" isn't a not-found error, it's
* a legitimately empty account.
*
* Address/profile writes go through an explicit column allowlist
* (WRITABLE_ADDRESS_FIELDS/WRITABLE_PROFILE_FIELDS) rather than trusting
* Lunar\Models\Address/Customer's own $guarded = [] — that flag makes
* every column mass-assignable at the model layer, including
* customer_id on addresses, so a caller passing through an unfiltered
* request array (a real risk for a storefront controller built directly
* against this service) could otherwise reassign an address to a
* different customer entirely, or overwrite created_at/id. Arr::only()
* silently drops anything not on the allowlist rather than erroring —
* this is a safety boundary, not form validation (a storefront still
* validates its own request shape before calling this).
*
* Authorization here IS the ownership scoping itself, not a separate
* layer bolted on top — there is deliberately no Laravel Policy/Gate
* class for Order/Address, since a policy is meaningless without a
* controller calling authorize() against it, and this branch is scoped
* to backend services only (no routes/controllers — see the branch's own
* commit history). Every public method below takes Authenticatable $user
* as a required first argument and resolves everything else (Order,
* Address, Customer) strictly through that user's own
* latestCustomer() — there is no method that looks anything up by a bare
* id alone. A future storefront controller cannot "forget" the
* authorization check the way it could with a separate policy class,
* because the check IS how every lookup happens; skipping it isn't an
* option the method signatures allow.
*/
class CustomerAccountService
{
private const WRITABLE_ADDRESS_FIELDS = [
'title', 'first_name', 'last_name', 'company_name',
'line_one', 'line_two', 'line_three', 'city', 'state', 'postcode',
'delivery_instructions', 'contact_email', 'contact_phone',
'country_id', 'shipping_default', 'billing_default',
];
private const WRITABLE_PROFILE_FIELDS = [
'title', 'first_name', 'last_name', 'company_name', 'vat_no',
];
public function customer(Authenticatable $user): ?Customer
{
/** @var Customer|null */
return $user->latestCustomer();
}
/**
* Placed orders only (placed_at IS NOT NULL) — a draft/abandoned
* order with no placed_at is checkout-in-progress state, not
* something that belongs in order history.
*/
public function orders(Authenticatable $user, int $perPage = 15): LengthAwarePaginator
{
$customer = $this->customer($user);
if (! $customer) {
return new LengthAwarePaginator([], 0, $perPage);
}
return $customer->orders()
->whereNotNull('placed_at')
->latest('placed_at')
->paginate($perPage);
}
/**
* @throws OrderNotFoundException if $orderId doesn't belong to this
* customer, or belongs to a draft (never placed) order
*/
public function order(Authenticatable $user, int $orderId): Order
{
$customer = $this->customer($user);
$order = $customer
?->orders()
->whereNotNull('placed_at')
->with(['lines', 'shippingAddress', 'billingAddress', 'transactions', 'shipments'])
->find($orderId);
if (! $order) {
throw new OrderNotFoundException;
}
return $order;
}
public function addresses(Authenticatable $user): iterable
{
$customer = $this->customer($user);
return $customer?->addresses ?? collect();
}
/**
* @param array<string, mixed> $data Any key not in
* WRITABLE_ADDRESS_FIELDS is silently dropped — see this class's
* own docblock.
*/
public function createAddress(Authenticatable $user, array $data): Address
{
$customer = $this->customerOrFail($user);
$address = $customer->addresses()->create(Arr::only($data, self::WRITABLE_ADDRESS_FIELDS));
$this->enforceSingleDefault($customer, $address);
$address->refresh();
Event::dispatch(new CustomerAddressCreated($address, $user));
return $address;
}
/**
* @throws AddressNotFoundException if $addressId doesn't belong to
* this customer
*/
public function updateAddress(Authenticatable $user, int $addressId, array $data): Address
{
$address = $this->ownedAddress($user, $addressId);
$old = $address->only(array_keys(Arr::only($data, self::WRITABLE_ADDRESS_FIELDS)));
$address->update(Arr::only($data, self::WRITABLE_ADDRESS_FIELDS));
$this->enforceSingleDefault($address->customer, $address);
$address->refresh();
Event::dispatch(new CustomerAddressUpdated($address, $old, $user));
return $address;
}
/**
* @throws AddressNotFoundException if $addressId doesn't belong to
* this customer
*/
public function deleteAddress(Authenticatable $user, int $addressId): void
{
$address = $this->ownedAddress($user, $addressId);
$snapshot = $address->getAttributes();
$address->delete();
Event::dispatch(new CustomerAddressDeleted($snapshot, $user));
}
/**
* Lunar has no built-in action enforcing "at most one shipping
* default / one billing default per customer" — a raw update() could
* otherwise leave two addresses both flagged shipping_default. Runs
* after every create/update, unconditionally (cheap — at most two
* single-row UPDATEs, only fired when the just-written address
* itself is a default), clearing the flag on every OTHER address of
* the same customer.
*/
private function enforceSingleDefault(Customer $customer, Address $address): void
{
if ($address->shipping_default) {
$customer->addresses()->where('id', '!=', $address->id)->update(['shipping_default' => false]);
}
if ($address->billing_default) {
$customer->addresses()->where('id', '!=', $address->id)->update(['billing_default' => false]);
}
}
/**
* @throws AddressNotFoundException if $addressId doesn't belong to
* this customer
*/
private function ownedAddress(Authenticatable $user, int $addressId): Address
{
$customer = $this->customer($user);
$address = $customer?->addresses()->find($addressId);
if (! $address) {
throw new AddressNotFoundException;
}
return $address;
}
/**
* @param array<string, mixed> $data Any key not in
* WRITABLE_PROFILE_FIELDS is silently dropped — see this class's
* own docblock.
*/
public function updateProfile(Authenticatable $user, array $data): Customer
{
$customer = $this->customerOrFail($user);
$old = $customer->only(array_keys(Arr::only($data, self::WRITABLE_PROFILE_FIELDS)));
$customer->update(Arr::only($data, self::WRITABLE_PROFILE_FIELDS));
$customer->refresh();
Event::dispatch(new CustomerProfileUpdated($customer, $old, $user));
return $customer;
}
/**
* @throws LogicException if $user has no paired Customer at all —
* distinct from AddressNotFoundException/OrderNotFoundException
* (which mean "this id isn't yours"), this means the account
* itself is in an invariant-violating state the normal OTP-login
* cascade should never produce.
*/
private function customerOrFail(Authenticatable $user): Customer
{
$customer = $this->customer($user);
if (! $customer) {
throw new LogicException('This user has no paired Customer record.');
}
return $customer;
}
}
@@ -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' => 'Σύνολο'],
@@ -67,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' => 'Δημοφιλή'],
+16 -10
View File
@@ -2,25 +2,31 @@
namespace Modules\Core\Logging;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Database\Eloquent\Model;
/**
* Thin wrapper around Spatie Activity Log that standardises the log channel,
* actor (authenticated staff member), and property shape for all domain events.
* actor, and property shape for all domain events.
*
* All logs are written to the 'lunar' channel. The subject is always an
* Eloquent model, and the actor is resolved from the 'staff' guard at call time.
* Eloquent model. $causer defaults to the 'staff' guard's current user —
* every existing caller of this class is admin-side — but can be passed
* explicitly for a non-staff actor (e.g. a customer editing their own
* address on the `web` guard — see Modules\Core\Customer\Services\
* CustomerAccountService, which passes the acting User rather than
* relying on this default resolving to null for a web-guard session).
*/
class ActivityLogService
{
/**
* Log a creation event. $attributes describes the initial state.
*/
public function created(Model $subject, array $attributes): void
public function created(Model $subject, array $attributes, ?Authenticatable $causer = null): void
{
activity('lunar')
->performedOn($subject)
->causedBy(auth('staff')->user())
->causedBy($causer ?? auth('staff')->user())
->withProperties(['attributes' => $attributes])
->log('created');
}
@@ -28,11 +34,11 @@ class ActivityLogService
/**
* Log an update event. $old holds the previous values, $attributes the new ones.
*/
public function updated(Model $subject, array $old, array $attributes): void
public function updated(Model $subject, array $old, array $attributes, ?Authenticatable $causer = null): void
{
activity('lunar')
->performedOn($subject)
->causedBy(auth('staff')->user())
->causedBy($causer ?? auth('staff')->user())
->withProperties(['old' => $old, 'attributes' => $attributes])
->log('updated');
}
@@ -40,11 +46,11 @@ class ActivityLogService
/**
* Log a failed operation. $attributes provides context (e.g. error message, service).
*/
public function failed(Model $subject, array $attributes): void
public function failed(Model $subject, array $attributes, ?Authenticatable $causer = null): void
{
activity('lunar')
->performedOn($subject)
->causedBy(auth('staff')->user())
->causedBy($causer ?? auth('staff')->user())
->withProperties(['attributes' => $attributes])
->log('failed');
}
@@ -52,11 +58,11 @@ class ActivityLogService
/**
* Log a deletion event. $attributes provides context (e.g. reason, name).
*/
public function deleted(Model $subject, array $attributes): void
public function deleted(Model $subject, array $attributes, ?Authenticatable $causer = null): void
{
activity('lunar')
->performedOn($subject)
->causedBy(auth('staff')->user())
->causedBy($causer ?? auth('staff')->user())
->withProperties(['attributes' => $attributes])
->log('deleted');
}
@@ -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,
) {}
}
@@ -32,10 +32,6 @@ use ReflectionProperty;
* could return a real, honest failure — see Payment\Support\
* TransactionDriverAdapter's own docblock for that history.
*
* Fix, for capture: wrap the action's own action() closure so that, on
* Halt, we call $action->sendFailureNotification() ourselves before letting
* the Halt continue propagating — everything else is untouched.
*
* 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
@@ -43,15 +39,28 @@ use ReflectionProperty;
* 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 OrderRefundActionsExtension extends ViewPageExtension
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->fixFailureNotification($action),
'capture' => $this->fixCaptureAction($action),
default => $action,
},
$actions,
@@ -123,6 +132,41 @@ class OrderRefundActionsExtension extends ViewPageExtension
});
}
/**
* 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>
*/
@@ -163,37 +207,4 @@ class OrderRefundActionsExtension extends ViewPageExtension
return $reflected->getValue($object);
}
/**
* Wraps the action's own configured action() closure so that, if it
* halts (Lunar's closures throw via $action->halt() to signal failure —
* see this class's own docblock for why that alone never sends the
* notification queued via failureNotification()), we send that
* notification ourselves before letting the Halt continue propagating
* (still needed — it's what stops callMountedAction() from treating
* this as a success and closing the modal/committing the DB transaction).
*
* $this->evaluate() (not a plain call) matches exactly how Action::call()
* itself invokes the closure — Lunar's closures type-hint $data/$record/
* $action and rely on Filament's own container-style parameter
* resolution, not positional arguments.
*/
private function fixFailureNotification(Action $action): Action
{
$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;
}
});
}
}
@@ -8,7 +8,7 @@ use Filament\Tables\Table;
use Lunar\Admin\Support\Extending\BaseExtension;
/**
* Same fix as OrderRefundActionsExtension, applied to the order lines
* 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
@@ -0,0 +1,49 @@
<?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;
}
$method = PaymentMethod::where('type', $type)->first();
return $method?->translate('name') ?? $type;
}
}
@@ -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,43 +5,50 @@ 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;
use Modules\Core\Payment\Models\PaymentMethod;
use Modules\Core\Payment\Services\PaymentMethodCache;
/**
* The only place an Order's status column is written in reaction to a
* payment outcome. Registered against PaymentCaptured, PaymentAuthorized,
* AND PaymentRefunded (see OrderServiceProvider) — same handler for all
* three, differing only in which PaymentMethod column decides the
* resulting status and, for a refund, which PaymentMethod row that even
* is (see resolvePaymentMethod()).
* 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. Never fires from the PaymentRefunded path — a
* refund can only ever happen after an order was already placed.
*
* Deliberately does NOT react to PaymentVoided — see PaymentMethod's own
* docblock for why there's no void_status column at all yet.
* Deliberately does NOT react to PaymentVoided.
*/
class ApplyResolvedPaymentStatus
{
public function __construct(
private readonly PaymentMethodCache $paymentMethods,
private readonly OrderStatusWriter $writer,
private readonly OrderStatusFlow $flow,
) {}
public function handle(PaymentCaptured|PaymentAuthorized|PaymentRefunded $event): void
@@ -54,59 +61,59 @@ class ApplyResolvedPaymentStatus
$order = Order::findOrFail($orderId);
$method = $this->resolvePaymentMethod($event, $order);
$column = match (true) {
$event instanceof PaymentCaptured => 'captured_status',
$event instanceof PaymentAuthorized => 'authorized_status',
$event instanceof PaymentRefunded => 'refunded_status',
};
$status = $method?->{$column};
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 (! $wasPlaced && ! $event instanceof PaymentRefunded) {
if ($event instanceof PaymentCaptured) {
$this->advancePastAwaitingPayment($order, $event);
}
if (! $wasPlaced) {
$order->update(['placed_at' => $order->placed_at ?? now()]);
Event::dispatch(new OrderPlaced($order));
}
}
/**
* PaymentCaptured/PaymentAuthorized carry $event->type as the
* PaymentMethod.type that was actually charged — a direct lookup.
*
* PaymentRefunded's $event->type is the REFUND driver's own registry
* key (e.g. 'bank-transfer' — see BankTransferPaymentDriver::refund()),
* which may not correspond to any PaymentMethod row at all when the
* admin refunded through a different driver than the one that took
* the original payment (Payment\Support\TransactionDriverAdapter::
* refundVia()). refunded_status is a business decision about the
* ORIGINAL payment method, not the refund mechanism, so this instead
* finds the order's earliest successful capture/intent transaction —
* the actual payment the refund is reversing — and resolves that
* transaction's own driver (a real PaymentMethod.type) instead.
*/
private function resolvePaymentMethod(PaymentCaptured|PaymentAuthorized|PaymentRefunded $event, Order $order): ?PaymentMethod
private function advancePastAwaitingPayment(Order $order, PaymentCaptured $event): void
{
if (! $event instanceof PaymentRefunded) {
return $this->paymentMethods->all()->firstWhere('type', $event->type);
if ($order->status !== 'awaiting_payment') {
return;
}
$originalType = $order->transactions()
->whereIn('type', ['capture', 'intent'])
->where('success', true)
->oldest('created_at')
->value('driver');
$next = $this->flow->nextOptions($order);
$target = array_key_first($next);
return $originalType !== null
? $this->paymentMethods->all()->firstWhere('type', $originalType)
: null;
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,
]);
}
@@ -22,7 +22,7 @@ use Modules\Core\Payment\Events\PaymentRefunded;
* 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\OrderRefundActionsExtension). pay() exists so
* 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
@@ -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);
@@ -7,12 +7,13 @@ use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Component;
use Filament\Schemas\Components\Utilities\Get;
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 Lunar\Admin\Support\Forms\Components\TranslatedText;
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;
@@ -44,16 +45,16 @@ use Modules\Core\Payment\Services\PaymentMethodService;
* 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) is surfaced as its own table
* 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 "the driver code was removed" at a
* glance, not have both look like the same disabled state.
* "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.
*
* `authorized_status` only appears in the form when `capture_mode` is
* "Hold now, charge later" — it's simply unreachable for a "Charge
* immediately" method (that mode only ever produces PaymentCaptured,
* never PaymentAuthorized), so showing it unconditionally would just be
* a confusing, always-irrelevant field for most methods.
*/
class PaymentMethodResource extends Resource
{
@@ -76,7 +77,7 @@ class PaymentMethodResource extends Resource
->sortable(),
TextColumn::make('name')
->label('Name')
->searchable(),
->state(fn (PaymentMethod $record) => $record->translate('name')),
TextColumn::make('type')
->label('Type'),
TextColumn::make('driver')
@@ -85,13 +86,12 @@ class PaymentMethodResource extends Resource
IconColumn::make('driver_missing_at')
->label('Driver status')
->boolean()
->trueIcon('heroicon-o-exclamation-triangle')
->falseIcon('heroicon-o-check-circle')
->trueColor('danger')
->falseColor('success')
->tooltip(fn (PaymentMethod $record) => $record->driver_missing_at
? 'Driver not found as of '.$record->driver_missing_at->diffForHumans()
: 'Driver resolves correctly'),
->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')
->updateStateUsing(fn (PaymentMethod $record, $state) => app(PaymentMethodService::class)
@@ -125,10 +125,9 @@ class PaymentMethodResource extends Resource
public static function getFormComponents(): array
{
return [
TextInput::make('name')
TranslatedText::make('name')
->label('Name')
->required()
->maxLength(255),
->required(),
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.')
@@ -146,13 +145,6 @@ class PaymentMethodResource extends Resource
->default('pay')
->live()
->required(),
static::getOrderStatusSelect('captured_status', 'Order status once paid')
->helperText('Applied the moment a payment is fully charged.'),
static::getOrderStatusSelect('authorized_status', 'Order status once held')
->helperText('Applied the moment a hold is placed, before it\'s charged.')
->visible(fn (Get $get) => $get('capture_mode') === 'authorize'),
static::getOrderStatusSelect('refunded_status', 'Order status once refunded')
->helperText('Applied when a payment taken through this method is refunded — even if the refund itself is processed through a different method.'),
];
}
@@ -164,24 +156,6 @@ class PaymentMethodResource extends Resource
->required();
}
/**
* Lunar's own Order::status is a plain, admin-extensible string
* (config('lunar.orders.statuses')) rather than a fixed enum —
* deliberately so a store can add its own custom status without a
* code change (see docs/payments.md). This Select still reads from
* that same open-ended list, just so an admin picks a real status
* instead of typing a slug from memory.
*/
private static function getOrderStatusSelect(string $name, string $label): Select
{
return Select::make($name)
->label($label)
->options(collect(config('lunar.orders.statuses', []))
->map(fn (array $status) => $status['label'] ?? $status)
->all())
->native(false);
}
public static function getPages(): array
{
return [
@@ -206,7 +180,7 @@ class PaymentMethodResource extends Resource
->icon('heroicon-o-pencil-square')
->schema(static::getFormComponents())
->fillForm(fn (PaymentMethod $record) => $record->only([
'name', 'type', 'driver', 'capture_mode', 'captured_status', 'authorized_status', 'refunded_status',
'name', 'type', 'driver', 'capture_mode',
]))
->action(fn (PaymentMethod $record, array $data) => app(PaymentMethodService::class)->update($record, $data));
}
@@ -260,4 +234,33 @@ class PaymentMethodResource extends Resource
return app(PaymentDriverRegistry::class)->label($key) ?? $key;
}
/**
* 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
{
$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.';
}
}
@@ -9,18 +9,17 @@ 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\PaymentDriverRegistry — this endpoint is
@@ -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);
}
}
+15 -12
View File
@@ -4,30 +4,30 @@ namespace Modules\Core\Payment\Models;
use Illuminate\Database\Eloquent\Casts\AsArrayObject;
use Illuminate\Database\Eloquent\Model;
use Lunar\Base\Traits\HasTranslations;
/**
* 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'],
* ApplyCashOnDeliveryFee's lookup key, every Payment event's $type).
* - name: admin-facing label.
* ApplyPaymentMethodFee's lookup key, every Payment event's $type).
* - name: admin-facing label, locale-keyed JSON (e.g.
* {"en": "Cash On Delivery", "el": "Αντικαταβολή"}) — same shape/
* resolution as Product/Collection names (Lunar\Base\Traits\
* HasTranslations), just applied directly to this column rather than
* through attribute_data, since this is a merchant settings row, not
* a catalog attribute. Rendered in Filament via Lunar's own
* Lunar\Admin\Support\Forms\Components\TranslatedText — one input per
* configured Language row, no bespoke translation UI. Resolve a
* display string with $method->translate('name') (locale defaults to
* app()->getLocale(), falling back to the store's default language).
* - 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.
* - captured_status / authorized_status / refunded_status: the
* Order::status value Modules\Core\Order\Listeners\
* ApplyResolvedPaymentStatus applies on a PaymentCaptured/
* PaymentAuthorized/PaymentRefunded event. For a refund, this is
* always the ORIGINAL payment method's row (the one the customer
* actually paid with), never the driver the refund itself was routed
* through (Payment\Support\TransactionDriverAdapter::refundVia() may
* use a different one entirely — e.g. a cash-on-delivery order
* refunded via a Bank Transfer driver with no PaymentMethod row of
* its own) — see that listener's own docblock.
* - 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
@@ -38,12 +38,15 @@ use Illuminate\Database\Eloquent\Model;
*/
class PaymentMethod extends Model
{
use HasTranslations;
protected $guarded = [];
protected $casts = [
'enabled' => 'boolean',
'position' => 'integer',
'driver_missing_at' => 'datetime',
'name' => 'array',
'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);
}
}
@@ -70,8 +70,7 @@ class PaymentMethodService
public function delete(PaymentMethod $method): void
{
$snapshot = $method->only([
'id', 'type', 'name', 'driver', 'capture_mode',
'captured_status', 'authorized_status', 'position', 'enabled',
'id', 'type', 'name', 'driver', 'capture_mode', 'position', 'enabled',
]);
$method->delete();
+126
View File
@@ -0,0 +1,126 @@
<?php
namespace Modules\Core\Payment\Support;
use Lunar\Models\Contracts\Currency as CurrencyContract;
use Stripe\Charge;
use Stripe\StripeClient;
/**
* First-party replacement for Lunar\Stripe\Facades\Stripe +
* Lunar\Stripe\Managers\StripeManager — lunarphp/stripe was removed once
* Modules\Core\Payment\Drivers\StripePaymentDriver already replaced every
* bit of Lunar's own Stripe payment flow (see that class's own docblock);
* all that remained load-bearing from the package was raw API-client
* access and amount conversion, neither of which is Lunar-specific. Only
* the methods StripePaymentDriver actually called are kept — no
* fetchOrCreateIntent()/cart-bound helpers, which belonged to Lunar's own
* (unused) checkout flow.
*
* getClient()/getCharge() call the Stripe SDK directly rather than going
* through a facade — StripePaymentDriver resolves this class via the
* container instead, same as every other dependency it takes.
*/
class StripeManager
{
public function getClient(): StripeClient
{
return new StripeClient([
'api_key' => config('services.stripe.key'),
]);
}
public function getCharge(string $chargeId): Charge
{
return $this->getClient()->charges->retrieve($chargeId);
}
/**
* Zero-decimal currencies, per Stripe. The amount sent to Stripe is the
* major unit amount as-is.
*
* @see https://docs.stripe.com/currencies#zero-decimal
*/
protected const ZERO_DECIMAL_CURRENCIES = [
'bif', 'clp', 'djf', 'gnf', 'jpy', 'kmf', 'krw', 'mga', 'pyg',
'rwf', 'ugx', 'vnd', 'vuv', 'xaf', 'xof', 'xpf',
];
/**
* Three-decimal currencies, per Stripe. The amount sent to Stripe is the
* major unit amount multiplied by 1000.
*
* @see https://docs.stripe.com/currencies#three-decimal
*/
protected const THREE_DECIMAL_CURRENCIES = ['bhd', 'jod', 'kwd', 'omr', 'tnd'];
/**
* HUF, TWD and UGX are ISO zero-decimal currencies, but Stripe still
* requires amounts to be sent as if they had two decimal places.
*
* @see https://docs.stripe.com/currencies#special-cases
*/
protected const SPECIAL_ZERO_DECIMAL_CURRENCIES = ['huf', 'twd', 'ugx'];
/**
* Convert a Lunar price value to the amount expected by Stripe.
*
* Lunar stores prices as integers scaled by `Currency::decimal_places`,
* which merchants can set independently of what Stripe expects for a
* given currency. This converts back to the major unit amount first,
* then re-scales it to whatever sub-unit Stripe requires for the
* currency, so the result is correct regardless of how the merchant has
* configured `Currency::decimal_places`.
*
* @see https://docs.stripe.com/currencies
*/
public static function toStripeAmount(int $value, CurrencyContract $currency): int
{
return self::rescale($value, max($currency->decimal_places, 0), self::stripeDecimalPlaces($currency));
}
/**
* Convert an amount received from Stripe back to a Lunar price value,
* scaled by `Currency::decimal_places`. Inverse of `toStripeAmount()`.
*/
public static function fromStripeAmount(int $amount, CurrencyContract $currency): int
{
return self::rescale($amount, self::stripeDecimalPlaces($currency), max($currency->decimal_places, 0));
}
/**
* The number of decimal places Stripe expects amounts in for a currency.
*/
protected static function stripeDecimalPlaces(CurrencyContract $currency): int
{
$code = strtolower($currency->code);
// UGX is also in the zero-decimal list; the special case takes precedence.
if (in_array($code, self::SPECIAL_ZERO_DECIMAL_CURRENCIES, true)) {
return 2;
}
if (in_array($code, self::ZERO_DECIMAL_CURRENCIES, true)) {
return 0;
}
if (in_array($code, self::THREE_DECIMAL_CURRENCIES, true)) {
return 3;
}
return 2;
}
protected static function rescale(int $value, int $fromDecimalPlaces, int $toDecimalPlaces): int
{
$exponent = $toDecimalPlaces - $fromDecimalPlaces;
if ($exponent >= 0) {
return $value * (10 ** $exponent);
}
$divisor = 10 ** (-$exponent);
return intdiv(abs($value) + intdiv($divisor, 2), $divisor) * ($value < 0 ? -1 : 1);
}
}
@@ -54,7 +54,7 @@ class TransactionDriverAdapter
/**
* The PaymentDriverRegistry key $transaction was originally taken
* through — what refund()/capture() resolve against by default, and
* what Order\Filament\Extensions\OrderRefundActionsExtension defaults
* what Order\Filament\Extensions\OrderActionsExtension defaults
* its "Refund via" driver Select to, before an admin overrides it.
*/
public function driverKeyFor(Transaction $transaction): ?string
@@ -72,7 +72,7 @@ class TransactionDriverAdapter
* when refunding through the transaction's own original driver.
*
* Called directly by Order\Filament\Extensions\
* OrderRefundActionsExtension when the admin picks a different driver
* OrderActionsExtension when the admin picks a different driver
* in the refund modal, bypassing Lunar\Models\Transaction::refund()
* (whose fixed refund(int $amount, $notes = null) signature has no
* room for a driver override) — see that extension's own docblock.

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