Compare commits

...
6 Commits
38 changed files with 1599 additions and 90 deletions
+202 -40
View File
@@ -4,28 +4,135 @@ 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/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [0.17.4] - 2026-09-15 ## [Unreleased]
### Added ### 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 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).
## [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 ### 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. - 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 `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 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 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\ which are Lunar-specific. Added first-party replacements: `Modules\Core\Payment\Support\
StripeManager` (API client + `toStripeAmount()`/`fromStripeAmount()`), `Modules\Core\Payment\ StripeManager`, `Modules\Core\Payment\Models\StripePaymentIntent`, `Modules\Core\Payment\Http\
Models\StripePaymentIntent` (now with a proper `context` array cast, replacing manual 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\ `json_encode`/`json_decode`), and `Modules\Core\Payment\Http\Middleware\
StripeWebhookMiddleware`. Added `database/migrations/..._create_stripe_payment_intents_table.php`, 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 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, 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 and only actually creates it on a genuinely fresh install). No behavior change for consuming
@@ -34,6 +141,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [0.17.2] - 2026-09-15 ## [0.17.2] - 2026-09-15
### Fixed ### Fixed
- `Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus` never advanced `Order::status` past - `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 `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 could sit indefinitely at "awaiting payment" until a staff member manually clicked "Update
@@ -45,9 +153,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- The backoffice "Capture" action on the order page (Filament) called vendor Lunar's - The backoffice "Capture" action on the order page (Filament) called vendor Lunar's
`Lunar\Models\Transaction::capture()` directly, which resolves `Lunar\Facades\Payments` — an `Lunar\Models\Transaction::capture()` directly, which resolves `Lunar\Facades\Payments` — an
entirely separate, unused driver registry — and never dispatched `Modules\Core\Payment\Events\ 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 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\ payment pipeline at all (including the status-advance fix above). `Modules\Core\Order\Filament\
Extensions\OrderActionsExtension` (renamed from `OrderRefundActionsExtension`, since it now Extensions\OrderActionsExtension` (renamed from `OrderRefundActionsExtension`, since it now
fixes both the refund and capture header actions — see below) now routes capture through 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 `Modules\Core\Payment\Support\TransactionDriverAdapter::capture()`, the same app-level path
checkout-time captures use. checkout-time captures use.
@@ -62,6 +170,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
transactions recorded after this change — existing rows are not backfilled. transactions recorded after this change — existing rows are not backfilled.
### Changed ### Changed
- `Modules\Core\Order\Filament\Extensions\OrderRefundActionsExtension` renamed to - `Modules\Core\Order\Filament\Extensions\OrderRefundActionsExtension` renamed to
`OrderActionsExtension` — the class now fixes both the refund and capture header actions on the `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. order page, not just refund, so the old name undersold its scope.
@@ -69,6 +178,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [0.17.1] - 2026-09-15 ## [0.17.1] - 2026-09-15
### Fixed ### Fixed
- `Modules\Core\Payment\Drivers\StripePaymentDriver::createAndConfirm()` only set - `Modules\Core\Payment\Drivers\StripePaymentDriver::createAndConfirm()` only set
`automatic_payment_methods` when no `payment_method` was given — the actual checkout flow always `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 sends one, so it was omitted, and Stripe fell back to whatever payment methods are enabled in the
@@ -80,6 +190,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [0.17.0] - 2026-09-14 ## [0.17.0] - 2026-09-14
### Added ### Added
- `Modules\Core\Order\Notifications\OrderPlacedNotification` — an order confirmation email, - `Modules\Core\Order\Notifications\OrderPlacedNotification` — an order confirmation email,
registered against `Modules\Core\Checkout\Events\OrderPlaced` (fires exactly once per order, 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 regardless of `capture_mode`/driver). Previously only a Stripe (auto-captured) order triggered
@@ -95,7 +206,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
`always`/`backorder` variants are deliberately left alone (their stock has no purchasing `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 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\ 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 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 order decrements its stock") — the search index's `in_stock` filter now reflects the change
immediately rather than only on the next scheduled reindex. immediately rather than only on the next scheduled reindex.
- `Modules\Core\Cart\Services\CartLifecycleService` — the single source of truth for the four - `Modules\Core\Cart\Services\CartLifecycleService` — the single source of truth for the four
@@ -140,12 +251,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
courier might not reconcile cash for weeks after an order is already marked completed. courier might not reconcile cash for weeks after an order is already marked completed.
### Changed ### Changed
- **Order status model, redesigned from scratch.** `Order.status` is a single column again - **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, (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 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 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\ journey, not three simultaneous dials). Now driven by `Modules\Core\Order\Services\
OrderStatusFlow`, a pure transition-table service offering exactly two sequences — carrier and OrderStatusFlow`, a pure transition-table service offering exactly two sequences — carrier and
store-pickup (`Order::isStorePickupOrder()`) — never four; payment method (prepaid vs. COD) 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 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 Filament order page's several guided buttons are replaced by three header actions: "Update
@@ -194,14 +306,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
redirect a parcel to a different locker than the one the customer picked at checkout; it's only 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. editable for the (current, checkout-UI-less) case where nothing set it yet.
- New "Shipments" section on the order page (`Modules\Core\Shipping\Extensions\ - New "Shipments" section on the order page (`Modules\Core\Shipping\Extensions\
OrderShipmentsExtension`, between Transactions and Timeline) — "Create Shipment" previously had no 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 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 + 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 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 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 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\ 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 Http\Controllers\DownloadShipmentLabelController` (short-lived signed URL, same auth model as
Lunar's own vendor order-PDF download) — the only other place that called Lunar's own vendor order-PDF download) — the only other place that called
`CarrierFulfillmentInterface::printLabel()` (`ManagePickupManifests`' bulk "Print" action) `CarrierFulfillmentInterface::printLabel()` (`ManagePickupManifests`' bulk "Print" action)
discarded the returned bytes entirely; this is the first place in the codebase that actually discarded the returned bytes entirely; this is the first place in the codebase that actually
@@ -214,16 +326,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
plain `int $shipment` and looking the record up directly in the controller. 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 - `Modules\Core\Shipping\Enums\TrackingStatus::Failed` — previously unused — is now wired to the
new `delivery_failed` status via `Modules\Core\Order\Listeners\ new `delivery_failed` status via `Modules\Core\Order\Listeners\
MarkDeliveryFailedOnCarrierCheckpoint`, from which staff can retry dispatch or convert to a MarkDeliveryFailedOnCarrierCheckpoint`, from which staff can retry dispatch or convert to a
return. return.
- Fixed a separate, unrelated bug hit while testing the above: `Lunar\Shipping\Models\ - Fixed a separate, unrelated bug hit while testing the above: `Lunar\Shipping\Models\
ShippingMethod::macro('isStorePickup', ...)` silently never registered — `Lunar\Base\Traits\ ShippingMethod::macro('isStorePickup', ...)` silently never registered — `Lunar\Base\Traits\
HasModelExtending::__callStatic()` (used by every `Lunar\Base\BaseModel` subclass that doesn't HasModelExtending::__callStatic()` (used by every `Lunar\Base\BaseModel` subclass that doesn't
declare its own `macro()`, `ShippingMethod` included) intercepts *every* unmatched static call 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 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 returned `false` and every order was silently treated as carrier-fulfilled — including store-pickup
ones. `Order::isStorePickupOrder()` (the only caller) now reads `ShippingMethod.data ones. `Order::isStorePickupOrder()` (the only caller) now reads `ShippingMethod.data
['fulfillment_type']` directly instead of going through the broken macro. ['fulfillment_type']` directly instead of going through the broken macro.
- `CartResource::getEloquentQuery()` no longer filters to carts with a known `user_id`/ - `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 `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), exclusion (an anonymous cart has nothing a staff member could click into — no name, no email),
@@ -272,7 +384,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
broke the relation manager's Livewire component mount, surfacing as a CSRF-token 419 redirect broke the relation manager's Livewire component mount, surfacing as a CSRF-token 419 redirect
loop specifically on `/boboko/manifests/{id}`. loop specifically on `/boboko/manifests/{id}`.
- "Create Shipment"'s ACS branch gained a "Number of packages" field (`ShipmentRequest:: - "Create Shipment"'s ACS branch gained a "Number of packages" field (`ShipmentRequest::
$packageCount`, already plumbed through to ACS's `Item_Quantity`/`persistMultipartVouchers()` but $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 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 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 field was relabeled "Total weight (kg)" to make explicit that ACS bills by one total shipment
@@ -281,6 +393,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [0.16.3] - 2026-09-10 ## [0.16.3] - 2026-09-10
### Fixed ### Fixed
- Stripe `createAndConfirm()` built its `PaymentIntent` params with - Stripe `createAndConfirm()` built its `PaymentIntent` params with
`'automatic_payment_methods' => isset($data['payment_method']) ? null : ['enabled' => true]`. The `'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 Stripe PHP SDK does not omit `null`-valued params from `create()` — it serializes them to an empty
@@ -289,7 +402,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
`payment_method` was supplied (i.e. every real charge in this flow). Fixed by building `$params` `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`. 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 - `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 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 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()` 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 filters out identically. An admin had no way to tell "this method is silently absent at checkout
@@ -320,7 +433,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
`Lunar\Managers\CartSessionManager` memoizes one `Cart` instance per request, so this was true on `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 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 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* 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 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 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. by calling `$cart->recalculate()` instead, which forces the pipeline to re-run.
@@ -328,6 +441,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [0.16.2] - 2026-09-09 ## [0.16.2] - 2026-09-09
### Fixed ### Fixed
- `Lunar\Base\ShippingManifest` is a request-lifetime singleton whose `getOptions()` re-runs the - `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 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 `addOption()` keeps the first entry per `getIdentifier()` and silently drops any later one. In
@@ -344,16 +458,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [0.16.1] - 2026-09-09 ## [0.16.1] - 2026-09-09
### Fixed ### Fixed
- OTP login page (`resources/views/auth/filament/pages/login.blade.php`) had no visible spacing - 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. 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 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 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: 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 ## [0.16.0] - 2026-09-08
### Added ### Added
- `Modules\Core\Checkout\Services\CheckoutService::setRecoveryConsent(bool $consent): Cart` — the - `Modules\Core\Checkout\Services\CheckoutService::setRecoveryConsent(bool $consent): Cart` — the
shopper's promotional/abandoned-cart-recovery opt-in, given once during guest checkout and shopper's promotional/abandoned-cart-recovery opt-in, given once during guest checkout and
deliberately independent of `setShippingAddress()`/`setBillingAddress()`: consent is a deliberately independent of `setShippingAddress()`/`setBillingAddress()`: consent is a
@@ -369,9 +485,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 `Modules\Core\Checkout\Events\RecoveryConsentSet`. Newsletter opt-in is explicitly a separate
scope — never merged into this flag. scope — never merged into this flag.
- `Modules\Core\Checkout\Services\CheckoutService::initiatePayment()` now requires `bool - `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` 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, never exist without a recorded acceptance (refused, not created-then-flagged). On success,
writes `terms_accepted` (`true`), `terms_accepted_at` (ISO 8601), and writes `terms_accepted` (`true`), `terms_accepted_at` (ISO 8601), and
`terms_accepted_policy_version` onto the created `Order`'s own `meta` — the durable, `terms_accepted_policy_version` onto the created `Order`'s own `meta` — the durable,
@@ -392,6 +508,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [0.15.0] - 2026-09-07 ## [0.15.0] - 2026-09-07
### Changed ### Changed
- **Breaking:** `Modules\Core\Payment\Models\PaymentMethod` is now the full DB-instance layer for - **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` 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 already has via `ShippingMethod` — see `docs/payments.md`. Every value that used to live in
@@ -401,13 +518,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`, (admin-facing label, nothing played this role before), `capture_mode`, `captured_status`,
`authorized_status`, `position` (admin-controlled ordering, new — reorderable in the Filament `authorized_status`, `position` (admin-controlled ordering, new — reorderable in the Filament
table), `driver_missing_at`. `config('lunar.payments.types')` is gone entirely; `config/ 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). same pipeline wiring regardless of how many payment methods it configures).
- **Breaking:** `Modules\Core\Payment\Services\PaymentDriverResolver` is deleted, replaced by - **Breaking:** `Modules\Core\Payment\Services\PaymentDriverResolver` is deleted, replaced by
`Modules\Core\Payment\Services\PaymentDriverRegistry` — `register(string $key, string `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\ 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 extending `Illuminate\Support\Manager` — Payment's drivers implement several independent
capability interfaces at once, not one uniform contract). Built-ins (`OfflinePaymentDriver` capability interfaces at once, not one uniform contract). Built-ins (`OfflinePaymentDriver`
as `'offline'`, `StripePaymentDriver` as `'stripe'`) registered in as `'offline'`, `StripePaymentDriver` as `'stripe'`) registered in
@@ -436,6 +553,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. should be called; that's a merchant decision). Skip-if-exists, same as before.
### Added ### Added
- `php artisan boboko:payment:sync-drivers` — reconciles every `PaymentMethod` row's `driver` - `php artisan boboko:payment:sync-drivers` — reconciles every `PaymentMethod` row's `driver`
against `PaymentDriverRegistry`, setting `driver_missing_at` when a driver no longer resolves 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 (a package removed, a custom `register()` call deleted) and clearing it automatically if that
@@ -466,9 +584,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\Models\CoreTransaction` (a `Lunar\Models\Transaction` subclass) +
`Modules\Core\Payment\Support\TransactionDriverAdapter`, registered via `Modules\Core\Payment\Support\TransactionDriverAdapter`, registered via
`Lunar\Facades\ModelManifest::replace(Lunar\Models\Contracts\Transaction::class, `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 `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\Models\Transaction::driver()` calls Lunar's own, entirely separate
`Lunar\Facades\Payments::driver()` manager, which had never heard of any of this codebase's `Lunar\Facades\Payments::driver()` manager, which had never heard of any of this codebase's
driver keys. `CoreTransaction::driver()` returns `TransactionDriverAdapter` instead, which driver keys. `CoreTransaction::driver()` returns `TransactionDriverAdapter` instead, which
@@ -477,7 +595,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 system underneath, including correctly reporting failure (not a silently-faked success) when
the resolved driver doesn't implement `SupportsRefunds`/`SupportsCaptures`. the resolved driver doesn't implement `SupportsRefunds`/`SupportsCaptures`.
- `TransactionDriverAdapter::refundVia(Transaction $transaction, ?string $driverKey, int $amount, - `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, 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 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 gained a "Refund via" `Select` (every `PaymentDriverRegistry` driver implementing
@@ -512,18 +630,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 - New `payment_methods.refunded_status` column + form field (same `Select` pattern as
`captured_status`/`authorized_status`) — `ApplyResolvedPaymentStatus` now also reacts to `captured_status`/`authorized_status`) — `ApplyResolvedPaymentStatus` now also reacts to
`PaymentRefunded`, so `Order.status` actually changes on a refund; before this, only the `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 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 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 `$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`, 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 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 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 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. the same "the customer needs to see this changed" weight a refund does.
### Fixed ### Fixed
- Existing `PaymentMethod` rows seeded before this release (`cash-on-delivery`, `cash-in-hand`) - 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 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 backfill was required (not automated by the migration itself) to restore them to a resolvable
@@ -551,15 +670,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [0.14.0] - 2026-09-03 ## [0.14.0] - 2026-09-03
### Changed ### 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). - **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. - `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 ### 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. - `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 ## [0.13.0] - 2026-09-03
### Changed ### 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:** `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 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`. - **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`.
@@ -568,6 +690,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). - `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 ### 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\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. - `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. - 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.
@@ -577,22 +700,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). - `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 ### 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\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`). - `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 ## [0.13.1] - 2026-09-03
### Added ### 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`. - `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 ## [0.12.1] - 2026-09-03
### Fixed ### 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\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. - `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()`. - `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 ### 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. - `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. - `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. - `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.
@@ -600,12 +727,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [0.12.0] - 2026-09-03 ## [0.12.0] - 2026-09-03
### Changed ### 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\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. - **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`. - `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. - 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 ### 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::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::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). - `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).
@@ -616,11 +745,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [0.11.1] - 2026-09-01 ## [0.11.1] - 2026-09-01
### Fixed ### 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. - `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 ## [0.11.0] - 2026-09-01
### Added ### 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\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\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`. - `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`.
@@ -629,28 +760,33 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [0.10.1] - 2026-09-01 ## [0.10.1] - 2026-09-01
### Added ### 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. - `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 ## [0.10.0] - 2026-08-31
### Changed ### 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`. - **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 ### 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\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\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. - `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::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. - `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. - `ApplyCashOnDeliveryFee` now reads its surcharge from `PaymentMethod` instead of static config, so it's admin-editable without a deploy.
### Fixed ### 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. - `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 ## [0.9.0] - 2026-08-29
### Added ### 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\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\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). - `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).
@@ -660,17 +796,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. - `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
- 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 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. - 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 ## [0.8.0] - 2026-08-27
### Added ### 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`. - `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 ## [0.7.0] - 2026-08-27
### Added ### 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\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\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). - `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).
@@ -678,6 +817,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`. - `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 ### 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:** 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\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. - **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.
@@ -686,29 +826,35 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [0.6.1] - 2026-08-27 ## [0.6.1] - 2026-08-27
### Added ### 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\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\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 ### 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:** `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`. - **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 ## [0.6.0] - 2026-08-27
### Added ### 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"). - `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 ### 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`). - **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_*`. - `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. - 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 ### 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\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`. - `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 ### 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: - 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\ProductService` → `Modules\Core\Product\Services\ProductService`
- `Modules\Core\Catalog\ProductFilters` → `Modules\Core\Product\DTOs\ProductFilters` - `Modules\Core\Catalog\ProductFilters` → `Modules\Core\Product\DTOs\ProductFilters`
@@ -717,6 +863,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` - `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. 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: - 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\LocaleMiddleware` → `Modules\Core\Localization\Middleware\LocaleMiddleware`
- `Modules\Core\Localization\LanguageCacheObserver` → `Modules\Core\Localization\Observers\LanguageCacheObserver` - `Modules\Core\Localization\LanguageCacheObserver` → `Modules\Core\Localization\Observers\LanguageCacheObserver`
@@ -728,26 +875,31 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [0.5.4] - 2026-08-26 ## [0.5.4] - 2026-08-26
### Added ### 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"). - `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 ## [0.5.3] - 2026-08-26
### Fixed ### 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')`. - `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 ## [0.5.2] - 2026-08-26
### Fixed ### 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"). - `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 ## [0.5.1] - 2026-08-25
### Added ### 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`). - `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 ## [0.5.0] - 2026-08-24
### Added ### 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. - **`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. - `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. - 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.
@@ -758,26 +910,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. - `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 ### 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. - 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 ## [0.4.0] - 2026-08-06
### Added ### 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. - **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 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. - **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\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. - `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. - **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: - **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. - `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. - `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 ## [0.3.0] - 2026-07-12
### Added ### 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. - **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. - `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. - 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.
@@ -785,17 +940,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [0.2.0] - 2026-07-10 ## [0.2.0] - 2026-07-10
### Added ### 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). - **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. - **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. - 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). - **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 ### 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. - `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 ## [0.1.0] - 2026-07-09
### Added ### 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. - **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. - **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. - `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.
@@ -810,6 +968,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. - `CONTRIBUTE.md` — local dev setup (path-repo + `bin/dc-core.sh`), and the manual DB-verification workflow used to build this feature.
### Fixed ### 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. - `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. - `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.
@@ -818,6 +977,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
First release. First release.
### Added ### Added
- OTP-based authentication built around `User` instead of `Customer` (`UserOtpService`, `UserOtpMail`), replacing the earlier customer-scoped OTP flow. - 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. - `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. - `UserRelationManager` for managing users from the customer resource in the panel.
@@ -828,7 +988,9 @@ First release.
- `docs/modules.md` documenting module structure. - `docs/modules.md` documenting module structure.
### Removed ### Removed
- `CustomerOtpMail` and `CustomerOtpService`, superseded by the user-based OTP flow. - `CustomerOtpMail` and `CustomerOtpService`, superseded by the user-based OTP flow.
### Dependencies ### Dependencies
- Added explicit `symfony/yaml` requirement (used directly by `Stoic::loadConfig()`). - Added explicit `symfony/yaml` requirement (used directly by `Stoic::loadConfig()`).
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "boboko/core", "name": "boboko/core",
"description": "Core module — authentication and shared panel behaviour", "description": "Core module — authentication and shared panel behaviour",
"type": "library", "type": "library",
"version": "0.17.4", "version": "0.17.5",
"autoload": { "autoload": {
"psr-4": { "psr-4": {
"Modules\\Core\\": "src/" "Modules\\Core\\": "src/"
+25
View File
@@ -65,4 +65,29 @@ return [
'return_window_days' => 14, '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,
],
],
]; ];
@@ -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))"
);
}
};
+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' => 'Περιφέρεια Δυτικής Μακεδονίας',
];
+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;
}
}
+95 -3
View File
@@ -2,16 +2,75 @@
namespace Modules\Core\Auth\Services; namespace Modules\Core\Auth\Services;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Mail; 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; 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 class UserOtpService
{ {
private const EXPIRY_MINUTES = 10; private const EXPIRY_MINUTES = 10;
private const CODE_LENGTH = 6; 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 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'); $model = config('auth.providers.users.model');
$user = $model::firstOrCreate(['email' => $email]); $user = $model::firstOrCreate(['email' => $email]);
@@ -19,6 +78,7 @@ class UserOtpService
$user->otp_code = $code; $user->otp_code = $code;
$user->otp_expires_at = now()->addMinutes(self::EXPIRY_MINUTES); $user->otp_expires_at = now()->addMinutes(self::EXPIRY_MINUTES);
$user->otp_attempts = 0;
$user->save(); $user->save();
Mail::to($user->email)->send(new UserOtpMail($user->name ?? $user->email, $code)); Mail::to($user->email)->send(new UserOtpMail($user->name ?? $user->email, $code));
@@ -26,23 +86,55 @@ class UserOtpService
return true; 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'); $model = config('auth.providers.users.model');
$user = $model::where('email', $email)->first(); $user = $model::where('email', $email)->first();
if (! $user) { if (! $user || ! $user->otp_expires_at || now()->isAfter($user->otp_expires_at)) {
return null; return null;
} }
if (! $user->otp_expires_at || $user->otp_code != $code || now()->isAfter($user->otp_expires_at)) { if ($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; return null;
} }
$user->otp_code = null; $user->otp_code = null;
$user->otp_expires_at = null; $user->otp_expires_at = null;
$user->otp_attempts = 0;
$user->save(); $user->save();
RateLimiter::clear($this->generationLimiterKey($email));
Auth::login($user);
$this->sessions->record($user, $request);
Event::dispatch(new CustomerLoggedIn($user));
return $user; return $user;
} }
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();
}
}
+14 -1
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()) { if (! Currency::whereDefault(true)->exists()) {
$this->components->info('Adding a default currency (USD)'); $this->components->info('Adding a default currency (USD)');
@@ -310,7 +320,10 @@ class InstallLunarCommand extends Command
PaymentMethod::create([ PaymentMethod::create([
'type' => 'cash-on-delivery', 'type' => 'cash-on-delivery',
'name' => 'Cash on Delivery', 'name' => [
'en' => 'Cash on Delivery',
'el' => 'Αντικαταβολή',
],
'driver' => 'cash-on-delivery', 'driver' => 'cash-on-delivery',
'capture_mode' => 'pay', 'capture_mode' => 'pay',
'position' => 0, 'position' => 0,
@@ -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;
}
}
+16 -10
View File
@@ -2,25 +2,31 @@
namespace Modules\Core\Logging; namespace Modules\Core\Logging;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
/** /**
* Thin wrapper around Spatie Activity Log that standardises the log channel, * 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 * 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 class ActivityLogService
{ {
/** /**
* Log a creation event. $attributes describes the initial state. * 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') activity('lunar')
->performedOn($subject) ->performedOn($subject)
->causedBy(auth('staff')->user()) ->causedBy($causer ?? auth('staff')->user())
->withProperties(['attributes' => $attributes]) ->withProperties(['attributes' => $attributes])
->log('created'); ->log('created');
} }
@@ -28,11 +34,11 @@ class ActivityLogService
/** /**
* Log an update event. $old holds the previous values, $attributes the new ones. * 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') activity('lunar')
->performedOn($subject) ->performedOn($subject)
->causedBy(auth('staff')->user()) ->causedBy($causer ?? auth('staff')->user())
->withProperties(['old' => $old, 'attributes' => $attributes]) ->withProperties(['old' => $old, 'attributes' => $attributes])
->log('updated'); ->log('updated');
} }
@@ -40,11 +46,11 @@ class ActivityLogService
/** /**
* Log a failed operation. $attributes provides context (e.g. error message, service). * 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') activity('lunar')
->performedOn($subject) ->performedOn($subject)
->causedBy(auth('staff')->user()) ->causedBy($causer ?? auth('staff')->user())
->withProperties(['attributes' => $attributes]) ->withProperties(['attributes' => $attributes])
->log('failed'); ->log('failed');
} }
@@ -52,11 +58,11 @@ class ActivityLogService
/** /**
* Log a deletion event. $attributes provides context (e.g. reason, name). * 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') activity('lunar')
->performedOn($subject) ->performedOn($subject)
->causedBy(auth('staff')->user()) ->causedBy($causer ?? auth('staff')->user())
->withProperties(['attributes' => $attributes]) ->withProperties(['attributes' => $attributes])
->log('deleted'); ->log('deleted');
} }
@@ -42,6 +42,8 @@ class OrderPaymentMethodSummaryExtension extends ViewPageExtension
return null; return null;
} }
return PaymentMethod::where('type', $type)->value('name') ?? $type; $method = PaymentMethod::where('type', $type)->first();
return $method?->translate('name') ?? $type;
} }
} }
@@ -12,6 +12,7 @@ use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\ToggleColumn; use Filament\Tables\Columns\ToggleColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use Lunar\Admin\Support\Forms\Components\TranslatedText;
use Modules\Core\Payment\Contracts\Configurable; use Modules\Core\Payment\Contracts\Configurable;
use Modules\Core\Payment\Events\PaymentMethodsReordered; use Modules\Core\Payment\Events\PaymentMethodsReordered;
use Modules\Core\Payment\Filament\Resources\PaymentMethodResource\Pages\ListPaymentMethods; use Modules\Core\Payment\Filament\Resources\PaymentMethodResource\Pages\ListPaymentMethods;
@@ -76,7 +77,7 @@ class PaymentMethodResource extends Resource
->sortable(), ->sortable(),
TextColumn::make('name') TextColumn::make('name')
->label('Name') ->label('Name')
->searchable(), ->state(fn (PaymentMethod $record) => $record->translate('name')),
TextColumn::make('type') TextColumn::make('type')
->label('Type'), ->label('Type'),
TextColumn::make('driver') TextColumn::make('driver')
@@ -124,10 +125,9 @@ class PaymentMethodResource extends Resource
public static function getFormComponents(): array public static function getFormComponents(): array
{ {
return [ return [
TextInput::make('name') TranslatedText::make('name')
->label('Name') ->label('Name')
->required() ->required(),
->maxLength(255),
TextInput::make('type') TextInput::make('type')
->label('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.') ->helperText('Machine-facing slug — stored on the cart/order, used by other code to identify this method. Cannot be changed once orders reference it.')
+14 -1
View File
@@ -4,6 +4,7 @@ namespace Modules\Core\Payment\Models;
use Illuminate\Database\Eloquent\Casts\AsArrayObject; use Illuminate\Database\Eloquent\Casts\AsArrayObject;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Lunar\Base\Traits\HasTranslations;
/** /**
* A merchant-configured payment method — the DB-instance layer, admin * A merchant-configured payment method — the DB-instance layer, admin
@@ -11,7 +12,16 @@ use Illuminate\Database\Eloquent\Model;
* shipping_methods table already has (see docs/payments.md): * shipping_methods table already has (see docs/payments.md):
* - type: unique, machine-facing slug (Cart::meta['payment_method'], * - type: unique, machine-facing slug (Cart::meta['payment_method'],
* ApplyPaymentMethodFee's lookup key, every Payment event's $type). * ApplyPaymentMethodFee's lookup key, every Payment event's $type).
* - name: admin-facing label. * - 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 * - driver: the Modules\Core\Payment\Services\PaymentDriverRegistry key
* — NOT the same as `type`, and not unique (two rows can share one * — NOT the same as `type`, and not unique (two rows can share one
* driver, e.g. two differently-named offline-style methods). * driver, e.g. two differently-named offline-style methods).
@@ -28,12 +38,15 @@ use Illuminate\Database\Eloquent\Model;
*/ */
class PaymentMethod extends Model class PaymentMethod extends Model
{ {
use HasTranslations;
protected $guarded = []; protected $guarded = [];
protected $casts = [ protected $casts = [
'enabled' => 'boolean', 'enabled' => 'boolean',
'position' => 'integer', 'position' => 'integer',
'driver_missing_at' => 'datetime', 'driver_missing_at' => 'datetime',
'name' => 'array',
'data' => AsArrayObject::class, 'data' => AsArrayObject::class,
]; ];
} }
+1
View File
@@ -25,6 +25,7 @@ class CoreServiceProvider extends ServiceProvider
$this->loadViewsFrom(__DIR__ . '/../../resources/views', 'core'); $this->loadViewsFrom(__DIR__ . '/../../resources/views', 'core');
Blade::anonymousComponentPath(__DIR__ . '/../../resources/views', 'core'); Blade::anonymousComponentPath(__DIR__ . '/../../resources/views', 'core');
$this->loadMigrationsFrom(__DIR__ . '/../../database/migrations'); $this->loadMigrationsFrom(__DIR__ . '/../../database/migrations');
$this->loadTranslationsFrom(__DIR__ . '/../../lang', 'core');
$this->publishes([ $this->publishes([
__DIR__ . '/../../config/core.php' => config_path('core.php'), __DIR__ . '/../../config/core.php' => config_path('core.php'),
+10
View File
@@ -7,7 +7,12 @@ use Illuminate\Support\ServiceProvider;
use Lunar\Facades\ModelManifest; use Lunar\Facades\ModelManifest;
use Lunar\Models\Contracts\Customer as LunarCustomer; use Lunar\Models\Contracts\Customer as LunarCustomer;
use Modules\Core\Auth\Events\UserCreated; use Modules\Core\Auth\Events\UserCreated;
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\Listeners\CreateCustomerForUser; use Modules\Core\Customer\Listeners\CreateCustomerForUser;
use Modules\Core\Customer\Listeners\LogCustomerAccountActivity;
use Modules\Core\Customer\Models\Customer; use Modules\Core\Customer\Models\Customer;
class CustomerServiceProvider extends ServiceProvider class CustomerServiceProvider extends ServiceProvider
@@ -17,5 +22,10 @@ class CustomerServiceProvider extends ServiceProvider
ModelManifest::replace(LunarCustomer::class, Customer::class); ModelManifest::replace(LunarCustomer::class, Customer::class);
Event::listen(UserCreated::class, CreateCustomerForUser::class); Event::listen(UserCreated::class, CreateCustomerForUser::class);
Event::listen(CustomerAddressCreated::class, [LogCustomerAccountActivity::class, 'handleAddressCreated']);
Event::listen(CustomerAddressUpdated::class, [LogCustomerAccountActivity::class, 'handleAddressUpdated']);
Event::listen(CustomerAddressDeleted::class, [LogCustomerAccountActivity::class, 'handleAddressDeleted']);
Event::listen(CustomerProfileUpdated::class, [LogCustomerAccountActivity::class, 'handleProfileUpdated']);
} }
} }
+7 -5
View File
@@ -27,6 +27,7 @@ use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\Filament\Pages\ManageShippingRates; use Modules\Core\Shipping\Filament\Pages\ManageShippingRates;
use Modules\Core\Shipping\Jobs\PollShipmentTrackingJob; use Modules\Core\Shipping\Jobs\PollShipmentTrackingJob;
use Modules\Core\Shipping\Listeners\InvalidateShippingOptions; use Modules\Core\Shipping\Listeners\InvalidateShippingOptions;
use Modules\Core\Shipping\Support\FulfillmentType;
use Modules\Core\Shipping\Models\Shipment; use Modules\Core\Shipping\Models\Shipment;
class ShippingServiceProvider extends ServiceProvider class ShippingServiceProvider extends ServiceProvider
@@ -80,8 +81,10 @@ class ShippingServiceProvider extends ServiceProvider
// resolveCarrier() for the same lookup pattern already used to // resolveCarrier() for the same lookup pattern already used to
// resolve a carrier driver from it). // resolve a carrier driver from it).
// //
// Reads ShippingMethod.data['fulfillment_type'] directly rather // Resolves via Modules\Core\Shipping\Support\FulfillmentType (driver-
// than through a ShippingMethod::macro('isStorePickup', ...) — // declared for acs/box-now, merchant-configured data['fulfillment_type']
// fallback for table-rate-shipping's generic drivers) rather than
// through a ShippingMethod::macro('isStorePickup', ...) —
// Lunar\Base\Traits\HasModelExtending::__callStatic() (used by // Lunar\Base\Traits\HasModelExtending::__callStatic() (used by
// Lunar\Shipping\Models\ShippingMethod via Lunar\Base\BaseModel) // Lunar\Shipping\Models\ShippingMethod via Lunar\Base\BaseModel)
// intercepts EVERY unmatched static call, including macro() // intercepts EVERY unmatched static call, including macro()
@@ -91,8 +94,7 @@ class ShippingServiceProvider extends ServiceProvider
// false. (Lunar\Models\Order is unaffected because it declares // false. (Lunar\Models\Order is unaffected because it declares
// its own macro() method directly, bypassing __callStatic // its own macro() method directly, bypassing __callStatic
// entirely — that's why Order::macro('isStorePickupOrder', ...) // entirely — that's why Order::macro('isStorePickupOrder', ...)
// below still works.) Defaults to 'carrier' (false) for any row // below still works.)
// saved before this field existed.
Order::macro('isStorePickupOrder', function () { Order::macro('isStorePickupOrder', function () {
/** @var Order $this */ /** @var Order $this */
$code = $this->shippingAddress?->shipping_option; $code = $this->shippingAddress?->shipping_option;
@@ -108,7 +110,7 @@ class ShippingServiceProvider extends ServiceProvider
// attribute avoids that entirely. // attribute avoids that entirely.
$method = ShippingMethod::where('code', $code)->first(); $method = ShippingMethod::where('code', $code)->first();
return ($method?->data['fulfillment_type'] ?? 'carrier') === 'store_pickup'; return $method && FulfillmentType::isStorePickup($method);
}); });
foreach ([CartLineAdded::class, CartLineUpdated::class, CartLineRemoved::class, CartCleared::class, ShippingAddressSet::class] as $event) { foreach ([CartLineAdded::class, CartLineUpdated::class, CartLineRemoved::class, CartCleared::class, ShippingAddressSet::class] as $event) {
+9 -2
View File
@@ -10,10 +10,12 @@ use Lunar\Shipping\Models\ShippingRate;
use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException; use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException;
use Modules\Core\Shipping\Concerns\CachesLivePricing; use Modules\Core\Shipping\Concerns\CachesLivePricing;
use Modules\Core\Shipping\Concerns\ResolvesFixedPricing; use Modules\Core\Shipping\Concerns\ResolvesFixedPricing;
use Modules\Core\Shipping\Contracts\DeclaresFulfillmentType;
use Modules\Core\Shipping\Contracts\SupportsLivePricing; use Modules\Core\Shipping\Contracts\SupportsLivePricing;
use Modules\Core\Shipping\Support\ShippingMethodName;
use Modules\Core\Shipping\Support\WeightCalculator; use Modules\Core\Shipping\Support\WeightCalculator;
class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing, DeclaresFulfillmentType
{ {
use ResolvesFixedPricing; use ResolvesFixedPricing;
use CachesLivePricing; use CachesLivePricing;
@@ -30,6 +32,11 @@ class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing
return 'ACS Courier'; return 'ACS Courier';
} }
public function fulfillmentType(): string
{
return 'carrier';
}
public function description(): string public function description(): string
{ {
return 'Live rate quote from ACS Courier.'; return 'Live rate quote from ACS Courier.';
@@ -84,7 +91,7 @@ class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing
$amount = (int) round(($response->valueOutput['Total_Ammount'] ?? 0) * 100); $amount = (int) round(($response->valueOutput['Total_Ammount'] ?? 0) * 100);
return new ShippingOption( return new ShippingOption(
name: $shippingMethod->name ?: $this->name(), name: ShippingMethodName::resolve($shippingMethod) ?: $this->name(),
description: $shippingMethod->description ?: $this->description(), description: $shippingMethod->description ?: $this->description(),
identifier: $shippingRate->getIdentifier(), identifier: $shippingRate->getIdentifier(),
price: new Price($amount, $cart->currency, 1), price: new Price($amount, $cart->currency, 1),
@@ -7,6 +7,7 @@ use Lunar\Shipping\DataTransferObjects\ShippingOptionRequest;
use Lunar\Shipping\Interfaces\ShippingRateInterface; use Lunar\Shipping\Interfaces\ShippingRateInterface;
use Lunar\Shipping\Models\ShippingRate; use Lunar\Shipping\Models\ShippingRate;
use Modules\Core\Shipping\Concerns\ResolvesFixedPricing; use Modules\Core\Shipping\Concerns\ResolvesFixedPricing;
use Modules\Core\Shipping\Contracts\DeclaresFulfillmentType;
/** /**
* Box Now has no pricing API, so this always resolves the method's normal * Box Now has no pricing API, so this always resolves the method's normal
@@ -14,7 +15,7 @@ use Modules\Core\Shipping\Concerns\ResolvesFixedPricing;
* flat-rate/ship-by drivers use. Does not implement SupportsLivePricing: * flat-rate/ship-by drivers use. Does not implement SupportsLivePricing:
* there is no live option to offer. * there is no live option to offer.
*/ */
class BoxNowRateDriver implements ShippingRateInterface class BoxNowRateDriver implements ShippingRateInterface, DeclaresFulfillmentType
{ {
use ResolvesFixedPricing; use ResolvesFixedPricing;
@@ -25,6 +26,11 @@ class BoxNowRateDriver implements ShippingRateInterface
return 'Box Now Locker Delivery'; return 'Box Now Locker Delivery';
} }
public function fulfillmentType(): string
{
return 'carrier';
}
public function description(): string public function description(): string
{ {
return 'Deliver to a Box Now parcel locker.'; return 'Deliver to a Box Now parcel locker.';
@@ -6,6 +6,8 @@ use Lunar\DataTypes\ShippingOption;
use Lunar\Facades\Pricing; use Lunar\Facades\Pricing;
use Lunar\Shipping\Models\ShippingMethod; use Lunar\Shipping\Models\ShippingMethod;
use Lunar\Shipping\Models\ShippingRate; use Lunar\Shipping\Models\ShippingRate;
use Modules\Core\Shipping\Support\FulfillmentType;
use Modules\Core\Shipping\Support\ShippingMethodName;
/** /**
* Shared by any carrier driver that also supports Lunar's own price-break * Shared by any carrier driver that also supports Lunar's own price-break
@@ -31,12 +33,13 @@ trait ResolvesFixedPricing
} }
return new ShippingOption( return new ShippingOption(
name: $shippingMethod->name ?: $this->name(), name: ShippingMethodName::resolve($shippingMethod) ?: $this->name(),
description: $shippingMethod->description ?: $this->description(), description: $shippingMethod->description ?: $this->description(),
identifier: $shippingRate->getIdentifier(), identifier: $shippingRate->getIdentifier(),
price: $pricing->matched->price, price: $pricing->matched->price,
taxClass: $shippingRate->getTaxClass(), taxClass: $shippingRate->getTaxClass(),
taxReference: $shippingRate->getTaxReference(), taxReference: $shippingRate->getTaxReference(),
collect: FulfillmentType::isStorePickup($shippingMethod),
); );
} }
} }
@@ -0,0 +1,25 @@
<?php
namespace Modules\Core\Shipping\Contracts;
/**
* Optional contract a shipping rate driver implements to declare whether
* it fulfils via carrier delivery or in-store pickup — e.g.
* Modules\Core\Shipping\Carriers\Acs\AcsRateDriver and BoxNowRateDriver
* are unambiguously carrier-only, so this is a hardcoded fact about the
* driver, not something a merchant should have to configure per row.
*
* table-rate-shipping's own generic drivers (flat-rate, ship-by,
* free-shipping) don't implement this — they're genuinely ambiguous (a
* merchant could configure one for either carrier delivery or store
* pickup), so Modules\Core\Shipping\Support\FulfillmentType::resolve()
* falls back to ShippingMethod.data['fulfillment_type'] (still merchant-
* overridable) only for drivers that don't implement this contract.
*/
interface DeclaresFulfillmentType
{
/**
* @return 'carrier'|'store_pickup'
*/
public function fulfillmentType(): string;
}
@@ -11,37 +11,110 @@ use Filament\Forms\Components\Select;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
use Lunar\Admin\Support\Extending\ResourceExtension; use Lunar\Admin\Support\Extending\ResourceExtension;
use Lunar\Admin\Support\Forms\Components\TranslatedText;
use Lunar\Shipping\Facades\Shipping; use Lunar\Shipping\Facades\Shipping;
use Modules\Core\Shipping\Contracts\DeclaresFulfillmentType;
use Modules\Core\Shipping\Contracts\SupportsLivePricing; use Modules\Core\Shipping\Contracts\SupportsLivePricing;
use Modules\Core\Shipping\Support\ShippingMethodName;
class ShippingMethodResourceExtension extends ResourceExtension class ShippingMethodResourceExtension extends ResourceExtension
{ {
public function extendForm(Schema $schema): Schema public function extendForm(Schema $schema): Schema
{ {
return $schema->components([ return $schema->components(
...$this->replaceChargeByField( $this->replaceFulfillmentTypeField(
$this->replaceDriverField($schema->getComponents()) $this->replaceChargeByField(
), $this->replaceNameField(
$this->fulfillmentTypeSelect(), $this->replaceDriverField($schema->getComponents())
]); )
)
)
);
} }
/** /**
* ShippingMethod.data['fulfillment_type'] — 'carrier' (default) or * Replaces the vendor's plain-string `name` TextInput with
* 'store_pickup'. Same free-form-`data`-column pattern as charge_by * Lunar's own TranslatedText — `name` is now a locale-keyed JSON
* above, not a migrated column: ShippingMethod is a vendor * column (see database/migrations/..._make_shipping_methods_name_translatable.php),
* (lunarphp/table-rate-shipping) table, and this codebase avoids * same shape/resolution as PaymentMethod.name and Product/Collection
* forking vendor migrations for a merchant-configurable extra (see * names (Lunar\Base\Traits\HasTranslations).
* PaymentMethod.data.fee for the same convention on a different
* vendor-adjacent model).
*
* What this actually gates: Modules\Core\Shipping\Extensions\
* OrderViewExtension's "Create Shipment" action only makes sense for
* a 'carrier' method (it books a real carrier voucher) — a
* 'store_pickup' order instead moves through Order.status
* 'ready-for-pickup' -> a staff "Mark Picked Up" action, no shipment
* ever created. See docs/checkout.md for the full status-flow design.
*/ */
private function replaceNameField(array $components): array
{
return array_map(function (Component $component) {
if (method_exists($component, 'getName') && $component->getName() === 'name') {
return $this->translatedNameField();
}
if (in_array(HasChildComponents::class, class_uses_recursive($component), true)) {
$component->schema($this->replaceNameField($component->getChildComponents()));
}
return $component;
}, $components);
}
/**
* ShippingMethod.name is a locale-keyed JSON column (see database/
* migrations/..._make_shipping_methods_name_translatable.php), but
* ShippingMethod is a vendor Eloquent model with no cast declared for
* it — Lunar\Shipping\Models\ShippingMethod only casts `data`, and
* there's no ModelManifest contract wired up to swap in a first-party
* subclass that adds one (Contracts\ShippingMethod exists but is
* never bound — see this class's own git history/nameColumn() for
* the same gap on the read side). TranslatedText itself round-trips
* plain array state, so afterStateHydrated()/dehydrateStateUsing()
* decode/encode the raw JSON string at the field boundary instead —
* the model attribute is a string on the way in and out, only ever
* an array while Filament's schema state holds it.
*/
private function translatedNameField(): TranslatedText
{
$field = TranslatedText::make('name')
->label('Name')
->required()
->afterStateHydrated(function (TranslatedText $component, $state) {
$decoded = json_decode((string) $state, true);
$component->state(is_array($decoded) ? $decoded : []);
})
->dehydrateStateUsing(fn ($state) => json_encode(is_array($state) ? $state : []));
$field->expanded = true;
return $field;
}
/**
* Inserts the `fulfillment_type` Select right after `charge_by`, in
* the SAME Group (vendor's own `Group::make([getChargeByFormComponent()])
* ->columns(2)`) — formerly appended at the very end of the whole
* form, disconnected from `driver`/`charge_by`, the decisions it
* actually relates to. Only rendered at all for a driver that DOESN'T
* already declare its own fulfillment type (see Modules\Core\Shipping\
* Contracts\DeclaresFulfillmentType, Modules\Core\Shipping\Support\
* FulfillmentType) — acs/box-now are unambiguously carrier-only, so
* asking a merchant to also pick "Carrier delivery" for every ACS/Box
* Now method was redundant, error-prone config with no real decision
* behind it. Still offered for table-rate-shipping's generic drivers
* (flat-rate, ship-by, free-shipping), which are genuinely ambiguous.
*/
private function replaceFulfillmentTypeField(array $components): array
{
$result = [];
foreach ($components as $component) {
$result[] = $component;
if (method_exists($component, 'getName') && $component->getName() === 'charge_by') {
$result[] = $this->fulfillmentTypeSelect();
} elseif (in_array(HasChildComponents::class, class_uses_recursive($component), true)) {
$component->schema($this->replaceFulfillmentTypeField($component->getChildComponents()));
}
}
return $result;
}
private function fulfillmentTypeSelect(): Select private function fulfillmentTypeSelect(): Select
{ {
return Select::make('data.fulfillment_type') return Select::make('data.fulfillment_type')
@@ -52,9 +125,23 @@ class ShippingMethodResourceExtension extends ResourceExtension
]) ])
->default('carrier') ->default('carrier')
->required() ->required()
->visible(fn (Get $get) => $this->driverIsFulfillmentAmbiguous($get('../driver')))
->helperText('Whether an order using this method is handed to a carrier, or collected by the customer in person.'); ->helperText('Whether an order using this method is handed to a carrier, or collected by the customer in person.');
} }
private function driverIsFulfillmentAmbiguous(?string $driver): bool
{
if (! $driver) {
return true;
}
try {
return ! Shipping::driver($driver) instanceof DeclaresFulfillmentType;
} catch (InvalidArgumentException) {
return true;
}
}
/** /**
* Extend the vendor's cart_total/weight charge_by Select with a third * Extend the vendor's cart_total/weight charge_by Select with a third
* "live" option — only offered when the currently selected driver * "live" option — only offered when the currently selected driver
@@ -127,6 +214,10 @@ class ShippingMethodResourceExtension extends ResourceExtension
return $this->driverColumn(); return $this->driverColumn();
} }
if (method_exists($column, 'getName') && $column->getName() === 'name') {
return $this->nameColumn();
}
return $column; return $column;
}, $table->getColumns()) }, $table->getColumns())
); );
@@ -139,6 +230,21 @@ class ShippingMethodResourceExtension extends ResourceExtension
->formatStateUsing(fn ($state) => $this->driverLabel($state)); ->formatStateUsing(fn ($state) => $this->driverLabel($state));
} }
/**
* `name` is a locale-keyed JSON column (see Modules\Core\Shipping\
* Support\ShippingMethodName's own docblock for why it needs manual
* decoding rather than a model cast). Uses ->state() rather than
* ->formatStateUsing(), which would otherwise have Filament iterate a
* would-be array state as a multi-value list (one formatted cell per
* locale) instead of a single string.
*/
private function nameColumn(): TextColumn
{
return TextColumn::make('name')
->label('Name')
->state(fn ($record) => ShippingMethodName::resolve($record));
}
private function driverLabel(string $key): string private function driverLabel(string $key): string
{ {
$driver = collect(Shipping::getSupportedDrivers())->get($key); $driver = collect(Shipping::getSupportedDrivers())->get($key);
+60
View File
@@ -0,0 +1,60 @@
<?php
namespace Modules\Core\Shipping\Support;
use Lunar\Shipping\Facades\Shipping;
use Lunar\Shipping\Models\ShippingMethod;
use Modules\Core\Shipping\Contracts\DeclaresFulfillmentType;
/**
* The single source of truth for "is this ShippingMethod a carrier
* delivery or an in-store pickup" — replaces a merchant-facing
* data['fulfillment_type'] Select that used to exist for every method
* regardless of driver. Modules\Core\Shipping\Carriers\Acs\AcsRateDriver
* and BoxNowRateDriver are unambiguously carrier-only (see
* Modules\Core\Shipping\Contracts\DeclaresFulfillmentType's own
* docblock), so asking a merchant to also pick "Carrier delivery" for
* every ACS/Box Now method was redundant, error-prone config with no
* real decision behind it.
*
* table-rate-shipping's own generic drivers (flat-rate, ship-by,
* free-shipping) don't implement DeclaresFulfillmentType — a merchant
* could genuinely configure one for either purpose (e.g. "Flat Rate —
* Athens Store Pickup") — so those still fall back to the merchant-set
* data['fulfillment_type'], defaulting to 'carrier' when unset.
*/
class FulfillmentType
{
public static function resolve(ShippingMethod $method): string
{
$driver = collect(Shipping::getSupportedDrivers())->get($method->driver);
if ($driver instanceof DeclaresFulfillmentType) {
return $driver->fulfillmentType();
}
return $method->data['fulfillment_type'] ?? 'carrier';
}
public static function isStorePickup(ShippingMethod $method): bool
{
return static::resolve($method) === 'store_pickup';
}
/**
* Whether the merchant-facing "Fulfillment type" Select should be
* shown at all for a given driver — hidden entirely for a driver that
* already declares its own fulfillment type, since there is no real
* decision left for the merchant to make.
*/
public static function isConfigurableFor(?string $driverKey): bool
{
if ($driverKey === null) {
return true;
}
$driver = collect(Shipping::getSupportedDrivers())->get($driverKey);
return ! $driver instanceof DeclaresFulfillmentType;
}
}
@@ -0,0 +1,35 @@
<?php
namespace Modules\Core\Shipping\Support;
use Illuminate\Support\Arr;
use Lunar\Shipping\Models\ShippingMethod;
/**
* ShippingMethod.name is a locale-keyed JSON column (see database/
* migrations/..._make_shipping_methods_name_translatable.php, and
* Modules\Core\Shipping\Extensions\ShippingMethodResourceExtension for
* the Filament form/table side), but ShippingMethod is a vendor Eloquent
* model with no cast declared for it — Lunar\Shipping\Models\
* ShippingMethod only casts `data`, and there's no ModelManifest contract
* wired up by the package to swap in a first-party subclass that adds
* one (Contracts\ShippingMethod exists but is never bound). So `$method->
* name` returns the raw JSON string, not a decoded array — this resolves
* it the same way Lunar\Base\Traits\HasTranslations::translate() would,
* shared by every rate driver that builds a Lunar\DataTypes\ShippingOption
* (Modules\Core\Shipping\Concerns\ResolvesFixedPricing, Modules\Core\
* Shipping\Carriers\Acs\AcsRateDriver) plus the Filament table column.
*/
class ShippingMethodName
{
public static function resolve(ShippingMethod $method, ?string $locale = null): ?string
{
$decoded = json_decode((string) $method->getRawOriginal('name'), true);
if (! is_array($decoded)) {
return $method->getRawOriginal('name');
}
return Arr::get($decoded, $locale ?: app()->getLocale()) ?: Arr::first($decoded);
}
}