# Changelog All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [0.21.1] - 2026-09-25 ### Changed - `GuestOrderClaimer` and its `UserAuthenticated` listener moved from 3dealer's own `App\Services`/`App\Listeners` into `Modules\Core\Customer\Services\GuestOrderClaimer` / `Listeners\ClaimGuestOrdersOnLogin`, registered in `CustomerServiceProvider` — attaching a placed guest order to an account once its billing `contact_email` case-insensitively matches the account's email (only ever safe right after the shopper has proved they own that email: a login code, or 3dealer's own email-change confirmation) was already core-appropriate logic with no 3dealer-specific behavior. `Account\EmailController::verify()` now calls the core service directly. ## [0.21.0] - 2026-09-25 ### Added - `Modules\Core\File` — a generic, storage-backend-agnostic file registry: `Models\File` (a `files` table row per stored file — disk, path, original name, mime, size, a `purpose` tag, and a nullable polymorphic owner), `Services\FileService` (store/retrieve/download/exists/ delete/list/`pruneUnowned`, delegating every actual byte-level operation to a `Contracts\FileAdapterInterface` resolved per disk — `Adapters\LocalFileAdapter` today, the same contextual-binding pattern `Shipping\Contracts\CarrierFulfillmentInterface` already uses per carrier, so a future `S3FileAdapter` is one class and one more match arm, nothing else changes), and `Http\Controllers\DownloadFileController` — a signed-URL-only route (`files.download`) any consuming app can mint a link to, serving either inline (a preview) or as a forced download (`?download=1`). - `Modules\Core\File\Http\Controllers\UploadFileController` — an abstract base for "accept an upload, validate it, store it via `FileService`, return its id" endpoints. Which extensions/sizes are acceptable is deliberately left to a concrete subclass's own `purpose()`/`validationRules()` overrides (ordinary server-side PHP, never trusting anything the request itself claims about its own limits) — a real policy decision that can differ per site and even per product/field, not something a shared base class or config file could express safely. - `boboko:file:prune-unowned {purpose}` — deletes every unowned `File` of a given purpose past its grace period (`--hours`, default 24). Generic: any consuming app schedules it once per purpose string it stores files under. - `Modules\Core\Cart\Events\CartLineAdded`/`Checkout\Events\OrderPlaced` listeners (`File\Listeners\AttachCustomFieldFileToCartLine`/`TransferCustomFieldFileOwnership`) that re-point a `File`'s ownership from unowned → the real `CartLine` once one exists, then from that `CartLine` → the `OrderLine` an order is placed with — so a File referenced by a product custom field survives the cart it originated from being cleared, without ever being copied. ### Changed - The admin order-lines table's collapsible details dropdown (next to the existing price breakdown) now shows a product's custom-field answers (`OrderLine.meta.custom_fields`) — a bordered table matching the existing price-breakdown one, with a thumbnail preview and a download-icon link for a file answer, resolved through `File\Services\FileService`'s signed route. Previously never shown anywhere in the admin. - 3dealer's product custom-field photo upload (`CustomFieldUploadController`), cart line meta (`CartController::customFieldsMeta()`), and pruning (formerly its own `PruneCustomFieldUploads` command) now go through `Modules\Core\File` instead of a bespoke `Crypt::encryptString({disk, path, name, mime})` reference scheme — a cart/order line's file answer is now just a `File` row's `file_id`, with `File` as the single source of truth for every other detail. ## [0.20.2] - 2026-09-25 ### Changed - Product custom fields (`Product::$custom_fields`) moved off the main product edit form onto their own "Custom Fields" sub-page (`Modules\Core\Catalog\Filament\Pages\ ManageProductCustomFields`), alongside "Reviews" — the same admin pattern, registered from the same `Review\Filament\Extensions\ProductResourceExtension` (CorePlugin only allows one extension class per Lunar resource, and Review's already owns this one). - Each custom field's `label` and new `help_text` are now translatable per storefront language (`{locale: string}`, e.g. `{en: "...", el: "..."}`) instead of a single plain string — entered as a plain `TextInput` per configured language rather than Lunar's `TranslatedText` form component, which turned out to only resolve its state path correctly as a top-level form field, not nested inside a `Repeater` item (every value silently failed to save under that combination). `help_text` is optional and, unlike `label`, shown only on the product page, not the cart or checkout. - `Modules\Core\Catalog\Support\ProductDocumentLocalizer::withLocalizedFields()` now also resolves each `custom_fields` item's `label`/`help_text` to a single string for the current locale (falling back to the store's default language), the same `filled()`-over-`??` way as every other translated field — the storefront and cart still only ever see one resolved string per field, unaware the admin-side value became translatable. A product's custom fields saved before this change (plain string `label`, no `help_text`) still resolve correctly. ## [0.20.1] - 2026-09-24 ### Fixed - `Modules\Core\Catalog\Support\ProductDocumentLocalizer::withLocalizedFields()` — a translated attribute (name, description, ...) saved blank for the current locale kept the empty string instead of falling back to the store's default language, since `??` only falls back on a missing/null key, not an empty one. A product with no English copy yet showed a blank title/description on `/en/` instead of its Greek content. - `Modules\Core\Command\WipeCatalogCommand` — now also deletes every `CartLine` referencing a `product_variant` purchasable as part of the wipe (line items only, `Cart` records themselves are left alone). Previously, any cart still holding a line for a wiped variant crashed the entire storefront on every page load (`PricingManager::for()` throws when the variant a line points at no longer exists) until those dangling lines were removed by hand. - `Modules\Core\Command\MigrateImportCommand` now asks which language a Shopify export file's own text is written in before importing, instead of silently assuming it matches the store's default language — the two are independent facts, and a mismatch used to save every imported product's name/description under the wrong language. Backing class renamed `DefaultLocale` → `Modules\Core\MigrateImport\Services\ImportLocale` to stop implying that assumption. ### Changed - `Modules\Core\MigrateImport` reorganized to match every other module's layout (`Contracts/`, `DTOs/`, `Jobs/`, `Models/`, `Services/`) instead of loose files at each namespace root — no behavior change, but every `use` of `Importer`, `ImportSpec`, `ImporterFactory`, `ImportLocale`, `RunMigrateImportJob`, `ShopifyExportImporter`, `ShopifyCsvReader`, `ProductGroup`, `JudgeMeExportImporter`, and `JudgeMeCsvReader` moved to its new namespace. - `Modules\Core\MigrateImport\Shopify\Services\ShopifyExportImporter::import()` now dispatches one `Jobs\ImportShopifyProductJob` per product (via `Bus::batch()`) instead of importing every product inline in a single queued job. A large export's variants, resolvers, and media downloads accumulating in one long-lived process routinely exceeded `queue:work`'s `--memory` limit; the worker died mid-run, the container restarted, and the entire import started over from the first row every time, never actually finishing. Splitting into one job per product resets memory between products, and a restart now only repeats whichever single product was in flight. `Modules\Core\Catalog\Services\SkuBackfillService::backfill()` moved from running right after the import loop to the batch's `then()` callback, since it must wait for every product job to finish rather than firing the moment jobs are merely queued. ## [0.20.0] - 2026-09-23 ### Added - `Modules\Core\Catalog\Support\ProductFilterBuilder::withVisibility()` — every storefront product read (`ProductService::list()`/`getById()`/`getBySlug()`/`random()`/`facets()`/ `priceRange()`, and `ProductSearchService`) now excludes `status = "draft"` products unless `APP_DEBUG` is true. Previously nothing filtered by status anywhere in this service — a draft product was fully visible on the storefront in every environment, always. - Per-product custom input fields (`Modules\Core\Catalog\Models\Product::$custom_fields`) — a repeater on the product edit form lets a merchant define extra input a shopper fills in on that product's page before adding it to cart (a reference photo upload, personalization text, etc.), each field a `{key, type: text|textarea|file, label, required}` entry. Deliberately not a Lunar `ProductOption`: an option's values are a fixed, admin-authored list that define variants, which doesn't fit "the shopper uploads their own unique photo." Required a new first-party `Product` model (registered via `ModelManifest::replace()`) purely to add a cast and `$fillable` entry Lunar's own base model doesn't have for this column — see that class's own docblock for two real Lunar-integration bugs this surfaced (below). - `boboko:wipe-catalog` (`Modules\Core\Command\WipeCatalogCommand`) — irreversibly deletes every Product and everything that only exists because of a product (variants, variant prices, product-option assignments, images/media, associations, the `ImportMapping` rows tying them back to an external source, the Meilisearch index), leaving catalog structure other products could still reference untouched (ProductOption/ProductOptionValue definitions, Brands, Collections, Tags, Customer Groups). Gated by an OTP emailed to a real Staff account (reusing `Auth\Services\OtpService`, the same mechanism admin login already uses) plus typing the exact product count back — not a plain yes/no confirm. - `Modules\Core\Auth\Services\OtpService::generateAndSend()` gained an optional `$purpose` parameter (default `'login'`, fully backward compatible) — `OtpMail` picks its subject/intro copy from a small fixed set of known purposes, so a destructive-command confirmation code reads as "Confirm: Wipe Catalog," not the login flow's "Your login code." - `Modules\Core\Catalog\Services\SkuBackfillService` — the actual backfill logic behind `boboko:catalog:backfill-skus`, extracted so `MigrateImport\RunMigrateImportJob` can also call it automatically right after a Shopify import (gated on `$spec->source === 'shopify'`, the only source that creates variants at all) — no separate manual step needed after a migration. - `ProductSort::Popularity` — sorts by a new `order_count` field Meilisearch now indexes per product (trailing-year, physical order lines only, aggregated across a product's variants) — the same "popular" definition Lunar's own admin dashboard "Popular Products" widget already uses. Not wired into the storefront's sort dropdown yet; callable directly via `ProductService::list(sort: ProductSort::Popularity)`. ### Fixed - `MigrateImport\Shopify\ShopifyExportImporter` created every variant with Lunar's own column default `purchasable = 'always'` (purchasable regardless of stock) rather than respecting the real `Variant Inventory Qty` the import itself provides — now explicitly set to `'in_stock'`. Forward-only; does not retroactively touch variants from a prior import run. - `WipeCatalogCommand::wipe()` used `chunkById()` while deleting rows inside the loop — a known pitfall where `chunkById()` re-queries "id > lastSeenId" every iteration, so deleting rows shrinks the table out from under it and can silently skip products that were never actually deleted at all. Fixed by always re-querying the first N remaining rows instead of advancing an id cursor, so every product is visited exactly once regardless of how many are deleted out from under the query as it goes. - `WipeCatalogCommand` called `delete()`, not `forceDelete()`, on `Product`/`ProductVariant` — both use `SoftDeletes`, so an "irreversible" wipe only trashed rows, leaving them sitting in the table. Combined with the `chunkById` bug above, this left ~185 zero-variant ghost `Product` rows in practice, which then crashed the admin's own global search (Lunar's `ProductResource::getGlobalSearchResultDetails()` assumes every returned product — trashed ones deliberately included, by Lunar's own design — has at least one variant). Fixed to `forceDelete()`; the existing ghost rows were removed directly (none had live order/cart references). `wipe()` also now clears `'image'`-type `ImportMapping` rows, not just `'product'`/`'variant'`. - `ShopifyExportImporter::resolveOrImportImage()` trusted a cached `ImportMapping`'d `Media` object unconditionally — now verifies the row still exists and is still attached to the current product before reusing it, falling through to a fresh import/attach otherwise. Makes a re-import robust to orphaned media regardless of what left them behind (e.g. a prior `WipeCatalogCommand` run, before the fix above). - Cart admin view (`Cart\Filament\Resources\CartResource\Pages\ViewCart`) 500'd (`Lunar\Exceptions\MissingCurrencyPriceException`) for any cart still holding a line whose purchasable no longer exists (e.g. after `boboko:wipe-catalog`) — `Cart::calculate()` now has that exception caught, falling back to an uncalculated cart; every total field already rendered `?->formatted() ?? '—'`, so the page degrades to showing "—" instead of a 500. - Creating or editing a Payment Method offered "Capture mode" (Charge immediately / Hold now, charge later) even for `cash-on-delivery`, whose driver has no `authorize()` method at all — selecting "authorize" there would have fatally errored at checkout. Now hidden/ non-required unless the resolved driver implements `SupportsAuthorization`. - `Lunar\Base\Traits\Searchable::indexer()` (and its sibling filterable/sortable-attribute methods) resolve their configured indexer via `$config[self::class]` — but `self::class` inside a trait method is a compile-time literal bound to whichever class first `use`s the trait, so it always evaluates to `Lunar\Models\Product`, never a subclass, regardless of which instance calls it. `config/lunar/search.php`'s `'indexers'` map must stay keyed by `Lunar\Models\Product::class`, not the new `Product` subclass — keying it by the subclass made the lookup miss entirely and silently fall back to a near-empty default indexer, wiping every filterable/sortable attribute the index had. Caught live, reverted; documented in the config file itself so it isn't repeated. - `CustomerServiceProvider`/`CatalogServiceProvider` called `ModelManifest::replace()` directly from `boot()` — `LunarServiceProvider` (lunarphp/core) calls `Facades\ModelManifest:: register()` from its OWN `boot()`, re-discovering every `Lunar\Models\*` class and silently overwriting any `replace()` registered earlier in the provider boot order. Both now defer to `$this->app->booted()`, which only runs once every provider's `boot()` has completed. ## [0.19.0] - 2026-09-18 ### Added - `Modules\Core\Payment\Contracts\RequiresFulfillmentType` — a payment driver can now declare it only makes sense for one fulfillment type (carrier delivery vs. store pickup), the payment-side mirror of `Shipping\Contracts\DeclaresFulfillmentType`. `CheckoutService:: getPaymentMethods()` excludes a method whose driver disagrees with the cart's currently selected shipping method — `OfflinePaymentDriver` ("pay in store") now requires `store_pickup`, `CashOnDeliveryPaymentDriver` requires `carrier`. Previously every enabled, configured payment method was offered regardless of shipping choice, so a shopper picking a courier delivery could still see "Pay in store" (no staff member present to take cash), and a store-pickup shopper could see cash-on-delivery (meaningless — there is no delivery to collect payment on). No constraint is imposed before a shipping option is selected. - `Modules\Core\Payment\Events\PaymentDeferred` — dispatched by any payment driver whose `Pending` result will never resolve via a later gateway callback (currently only `CashOnDeliveryPaymentDriver`), distinct from a Stripe-style `Pending` that a webhook will still resolve. Handled by the new `Modules\Core\Order\Listeners\ MarkOrderPlacedOnDeferredPayment`, which sets `Order::placed_at`, dispatches `OrderPlaced`, and advances `status` past `awaiting_payment` — without ever touching `Order::paid`, which still only flips via staff explicitly marking a COD order received. - `Modules\Core\Order\Services\OrderPaymentResolutionService::resolveDeferredPayment()` — the status-advance half of the above, reusing the same "advance past `awaiting_payment`" logic a captured payment already uses. - `Modules\Core\Checkout\Exceptions\NoShippingAddressException`. - `Modules\Core\Shipping\Carriers\BoxNow\BoxNowClient::destinations()` — lists available Box Now lockers (`GET /destinations`), backing a plain, self-hosted locker picker on checkout; Box Now's own Destination Map JS widget only talks to their Production environment, making it unusable while developing against Stage credentials. - `config/shippingCarriers/boxnow.php`: `BOXNOW_PARTNER_ID` — issued alongside Box Now credentials, consumed only by their client-side map widget, never by `BoxNowClient`'s own REST authentication. - A "Tracking history" list under each shipment on the order page (`Shipping\Extensions\ OrderShipmentsExtension`) — every recorded carrier checkpoint, oldest first, not just the latest status. - `Modules\Core\Review\Services\ReviewService` and `ReviewEvents\ReviewReplied` — extracted from `ManageProductReviews`'s inline `$record->update()`, following the write-then-dispatch pattern used everywhere else. - `Modules\Core\Catalog\Services\StockService::decrementForOrder()` — extracted from `DecrementStockOnOrderPlaced`, isolating the atomic stock-decrement SQL and Meilisearch reindex from the listener itself. - `Modules\Core\Order\Services\OrderStatusFlow::isValidTransition()` — the single source of truth for "is this a legal next status," replacing several listeners' own hardcoded "only fire from status X" comparisons. ### Fixed - Cash-on-delivery orders were placed but never left `awaiting_payment`, were invisible in customer order history, never decremented stock, and the storefront's own post-checkout confirmation could never find them — `CashOnDeliveryPaymentDriver::pay()` returns `Pending`, which dispatched no event at all, so nothing ever set `Order::placed_at` or advanced `status`. Fixed by `PaymentDeferred`/`MarkOrderPlacedOnDeferredPayment` above. - Staff marking a COD order "paid" (`OrderFulfillmentService::markPaid()`) flipped `Order::paid`/`paid_at` but never recorded a `Transaction` row — no audit trail, and anything reading `$order->transactions` (paid-amount displays included) saw nothing. Now records a `capture` transaction via `TransactionRecorder`, the exact call site its own docblock had already anticipated ("a future admin action ... can write a row the same way"). - A confirmed cash-on-delivery shipment dispatched via ACS or Box Now never actually told the carrier to collect payment — `ShipmentRequest::$paymentMode`/`$amountToCollect` were defined on the DTO but no caller ever populated them, permanently dead-coding both carriers' COD branches (`AcsFulfillmentService`'s `Cod_Ammount`/`Cod_Payment_Way`, Box Now's `amountToBeCollected`). `OrderViewExtension`'s "Create Shipment" action now derives both from `OrderStatusFlow::isCod($order)` at dispatch time — never left to staff to remember. - `Modules\Core\Shipping\Jobs\PollShipmentTrackingJob`: one shipment's tracking lookup failing (a carrier 500, a malformed parcel response) aborted the rest of that carrier's shipments in the same batch — now individually caught and reported per shipment. - Every Box Now delivery request 400'd (`P405`, invalid phone number) for any customer whose phone was stored in local Greek format rather than full international — `contactNumber` is now normalized to `+30...` before every request. - Creating a Box Now shipment 400'd (`P401`/`P402`) whenever `BOXNOW_ORIGIN_LOCATION_ID` or the sender contact fields were unset — documented and confirmed against a live sandbox account. - Selecting a Box Now locker at checkout, then making any unrelated address-form edit afterward (even a delivery-instructions keystroke), silently discarded the locker choice — `Lunar\Actions\Carts\AddAddress` deletes and recreates the cart's shipping address row on every save, wiping whatever `meta` a prior save had written onto it. `CheckoutService::setShippingAddress()` now carries the locker forward across that recreation; `selectShippingOption()` clears it when switching away from Box Now, so a stale locker never resurfaces if the shopper switches back later. `Shipping\Extensions\OrderViewExtension`'s "Box Now locker ID" field is no longer locked read-only once a customer choice exists — staff can override it. - Creating a Box Now shipping method 500'd (`Array to string conversion` / invalid JSON insert) — the vendor `ListShippingMethod` page's `CreateAction` builds its form inline, bypassing `ShippingMethodResourceExtension`'s translated-name field entirely; `Filament\Pages\ ManageShippingRates`'s method picker and "Shipping Method" table column also queried/sorted the now-JSON `name` column directly in SQL (`could not identify an ordering operator for type json`), both resolved app-side instead. - Creating or editing a Payment Method: `capture_mode` ("Charge immediately" / "Hold now, charge later") was offered even for a driver with no `authorize()` method at all (`CashOnDeliveryPaymentDriver`), which would have fatally errored at checkout had "authorize" ever been selected — now hidden/non-required unless the driver implements `SupportsAuthorization`. A spurious `validation.required` on the translated Name field, and every new Payment Method silently saving at `position` 0 regardless of the intended "last in the list" default — both traced to the same cause: an `Action::schema()` modal only dehydrates fields backed by a real form component, so `fillForm()`'s defaults for `name`/ `position` were computed but never actually reached the saved record. - `Modules\Core\Auth\Services\UserOtpService::generateAndSend()` now dispatches `UserCreated` when a new `User` row is created — this event was previously never dispatched anywhere in this package at all, despite listeners existing for it. - Applied a deliberate queueing policy across every Order/Localization/Customer/Payment/ Catalog listener, judged case-by-case on "if the queue stalls for minutes/hours, does this cause a real functional break, not just cosmetic staleness" — `RecordPaymentTransaction`, `CompleteOrderOnPickedUp`, `CreateCustomerForUser`, and `DecrementStockOnOrderPlaced` stay synchronous (a stalled queue would mean a real ordering violation or oversell risk); cache flushes, activity logging, and carrier-checkpoint-driven fulfillment listeners are now queued. ## [0.18.1] - 2026-09-16 ### Added - `Modules\Core\Payment\Privacy\PaymentDataProvider` — `lunar_transactions` (`card_type`/ `last_four`) and `stripe_payment_intents` were previously uncovered by any Privacy provider. Pseudonymizes card metadata on erasure (same tax/accounting retention reasoning as `OrderDataProvider`); deletes the Stripe correlation rows outright, since their only purpose (resolving an async webhook callback) has already been served by the time an erasure request runs. No Stripe Customer object exists anywhere in this app to also request deletion of — see `docs/payments.md` "Reconciliation". - `Modules\Core\Auth\Privacy\UserSessionDataProvider` — `user_sessions` (`ip_address`, `user_agent`) was previously uncovered. User-scope only; deleted outright on erasure, no legal retention argument applies to login-session metadata. - `Modules\Core\Logging\Privacy\ActivityLogDataProvider` — Spatie's `activity_log` table (`Modules\Core\Logging\ActivityLogService`, plus several Lunar models' native `LogsActivity`) durably retained full PII snapshots in `properties` even after the real row was erased elsewhere. Redacts `properties` by subject (`Customer`/`Address`/`CartAddress`/`OrderAddress`/ `Transaction`) on erasure; deliberately never touches `causer_id`, which is an actor reference, not PII content. Must run before `AddressDataProvider` in `config('core.privacy.providers')` — see the class's own docblock. - `ErasureOutcome::Failed` — a provider throwing an exception is now a genuine, distinct outcome from `Skipped` (a deliberate no-op), surfaced in the erasure report rather than silently aborting the request. ### Fixed - `PrivacyService::completeErasure()` and `ExportDataSubjectJob::handle()` ran every registered provider through a plain `array_map()` with no per-provider error handling — one provider throwing aborted the entire request, discarding every other provider's already-computed result and leaving the request stuck `Pending`/`Failed` with no report at all. Both now catch per-provider (`PrivacyService::safeErase()`, `ExportDataSubjectJob::safeExport()`), logging the exception and recording `ErasureOutcome::Failed`/`ProviderExportResult::$error` for that one provider while every other provider's result is still recorded normally. Verified live: simulating a throwing provider mid-erasure now correctly completes the request with a mixed `erased`/`failed`/`erased` report instead of leaving it `Pending` forever. - `CartDataProvider`/`OrderDataProvider` never covered PII-adjacent keys living in `Cart.meta`/ `Order.meta`/`OrderAddress.meta` — `recovery_consent*`, `payment_method`, `checkout_fingerprint` (Cart), `terms_accepted*` (Order), and `box_now_locker` (OrderAddress) all survived an erasure request untouched. Both providers now clear these keys alongside their existing address/ free-text field erasure. - `CustomerDataProvider::eraseForUser()` left `otp_code`/`otp_expires_at`/`otp_attempts` on an otherwise-erased `User` row. Now cleared alongside name/email. - `Modules\Core\Privacy\Filament\Resources\DataErasureRequestResource`'s "Outcome" section referenced `docs/privacy.md` directly in staff-facing UI text (meaningless to a user with no repo access) and rendered the per-provider report as raw JSON strings via a `KeyValueEntry` (the wrong component for a list of structured rows). Replaced with a plain-language description and a proper `RepeatableEntry` table (Data category / Outcome badge / Reason). ### Changed - The 5 existing Privacy providers (`CustomerDataProvider`, `AddressDataProvider`, `OrderDataProvider`, `CartDataProvider`, `ReviewDataProvider`) moved out of `Modules\Core\Privacy\Providers` into their owning domain module's own `Privacy/` subdirectory (e.g. `Modules\Core\Order\Privacy\OrderDataProvider`) — `Modules\Core\Privacy` now owns only the shared contract, request lifecycle, and DTOs/enums. Matters concretely if a module is ever extracted into its own composer package: the provider that knows how to erase that module's data now travels with it, rather than being stranded in `Privacy` depending on a package that no longer ships in this repo. See `docs/privacy.md` for the full reasoning. ## [0.18.0] - 2026-09-16 ### Added Customer-portal backend groundwork — no routes/controllers/views yet (a storefront-facing UI is 3dealer's job once a frontend designer picks it up), but the boboko-owned services it needs to call now exist: - `Modules\Core\Auth\Services\UserOtpService::validate()` now actually logs the shopper in (`Auth::login()`, `web` guard) — previously it only returned the `User` model with no session established and no route/controller anywhere ever called it (the checkout page's "Login" tab was a disabled placeholder). `Auth::login()` alone is enough to merge/associate any active guest cart too — it fires `Illuminate\Auth\Events\Login`, which Lunar's own `Lunar\Listeners\CartSessionAuthListener` (registered unconditionally in core, no opt-in needed) already reacts to, honoring `config('lunar.cart.auth_policy')` (`'merge'` by default). An earlier draft of this also called `Cart::associate()` directly from this service — removed as redundant and actually wrong: it ran a second, separate association with a hardcoded `'merge'` policy that ignored whatever a consumer had actually set `auth_policy` to. Also fixed an unbounded brute-force window: a 6-digit code (1M combinations, was guessable for its full 10-minute expiry with no attempt cap) now invalidates itself after 5 wrong guesses (`users.otp_attempts`, new column), forcing a fresh code request rather than leaving a live one guessable indefinitely. - `Modules\Core\Auth\Events\CustomerLoggedIn` — dispatched on every successful OTP login (new user or returning), for a storefront to hook into (e.g. post-login redirect, analytics). - `Modules\Core\Customer\Services\CustomerAccountService` — the storefront-facing "My Account" API (mirrors `CartService`/`CheckoutService`'s shape): `orders()` (paginated, placed orders only), `order()`, `addresses()`, `createAddress()`/`updateAddress()`/`deleteAddress()`, `updateProfile()`. Every method is scoped to the given user's own `latestCustomer()` — there is no method that accepts a bare order/address id without also requiring the owning user, so a controller built on top of this can't leak one customer's data to another by trusting a client-supplied id alone (verified live: a second customer attempting to read/edit the first's address or order gets `AddressNotFoundException`/`OrderNotFoundException`, not the record). ### Fixed - `Modules\Core\Auth\Services\UserOtpService::validate()`'s wrong-guess counter (`otp_attempts`) was read-check-increment-saved with no locking — two guesses fired in parallel for the same user could each read the same pre-increment value and both save past `max_attempts`, letting an attacker exceed the 5-guess lockout by parallelizing requests instead of sending them serially. Now wrapped in a `DB::transaction()` with `lockForUpdate()` on the user row, so concurrent guesses serialize correctly against the shared counter. - The OTP code comparison used a plain `!=` rather than a timing-safe comparison. Now `hash_equals()`. ## [0.17.5] - 2026-09-15 ### Added - Greek translations for `Lunar\Models\Country`/`State` reference data (`lang/el/countries.php`, `lang/el/states.php`), keyed by the exact English spellings Lunar's own installer seeds for Greece (fetched from `data.lunarphp.io/countries+states.json`). Loaded via `loadTranslationsFrom()` under the `core::` namespace — a plain lang file, not `Modules\Core\Localization`'s DB-backed `TranslationService`, since this is fixed reference data, not admin-editable UI copy. A consuming app's storefront looks these up itself (e.g. `__('core::countries.'.$country->name)`) — core has no storefront UI of its own to wire this into. - `Modules\Core\Order\Filament\Extensions\OrderActionsExtension::fixCaptureAction()` — reroutes the backoffice "Capture" header action through `Modules\Core\Payment\Support\ TransactionDriverAdapter::capture()`, the same app-level payment pipeline checkout-time captures use, instead of vendor Lunar's `Lunar\Models\Transaction::capture()` (which resolved `Lunar\Facades\Payments`, an entirely separate, unused driver registry, and never dispatched `Modules\Core\Payment\Events\PaymentCaptured`). - `Modules\Core\Payment\Drivers\StripePaymentDriver::cardMetaFromIntent()` — extracts card brand/last-four digits from the Stripe PaymentIntent's `latest_charge`, populated into `PaymentResult::$meta` and mapped onto `Transaction.card_type`/`last_four` by `Modules\Core\Order\Services\TransactionRecorder`. Fixes the admin activity log's "Payment of :amount on card ending :last_four" line rendering with no digits, on both checkout-time and manual captures. Only applies to transactions recorded after this change. - `PaymentMethod.name` and `Lunar\Shipping\Models\ShippingMethod.name` are now locale-keyed JSON columns, rendered in Filament via Lunar's own `Lunar\Admin\Support\Forms\Components\ TranslatedText` — one input per configured `Language` row, same shape/resolution as Product/Collection names. Existing plain-string rows are preserved under the store's default language on migration. `ShippingMethod` has no model cast/`ModelManifest` extension point available (vendor table, `Contracts\ShippingMethod` exists but is never bound by the package), so its translation is decoded/encoded at the Filament field boundary and via the new `Modules\Core\Shipping\Support\ShippingMethodName::resolve()` helper, rather than a model cast. - `Modules\Core\Shipping\Contracts\DeclaresFulfillmentType` — lets a shipping rate driver declare whether it fulfils via carrier delivery or in-store pickup as a hardcoded fact about the driver (`AcsRateDriver`, `BoxNowRateDriver` both declare `'carrier'`), instead of asking a merchant to also pick "Carrier delivery" on every row regardless of driver. The merchant-facing "Fulfillment type" Select (`ShippingMethod.data['fulfillment_type']`) now only appears for table-rate-shipping's generic drivers (flat-rate, ship-by, free-shipping), which are genuinely ambiguous, and moved next to `charge_by` instead of trailing at the end of the form, disconnected from the decisions it relates to. `Modules\Core\Shipping\Support\ FulfillmentType::resolve()`/`isStorePickup()` is the new single source of truth, replacing a direct `data['fulfillment_type']` read in `Order::isStorePickupOrder()`. ### Fixed - `Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus` never advanced `Order::status` past `awaiting_payment` on a capture — only `paid`/`paid_at` were written, so a fully captured order could sit indefinitely at "awaiting payment" until a staff member manually clicked "Update Status". Now, on `PaymentCaptured` (not `PaymentAuthorized`), `status` advances to the next step in the order's flow, but only when it's still exactly `awaiting_payment`, so a duplicate/delayed capture event never regresses an order staff already moved further. - `Lunar\DataTypes\ShippingOption::$collect` (the flag `docs/checkout.md` documents as the mechanism for detecting a pickup option at checkout) was never actually set by any shipping rate driver — `Modules\Core\Shipping\Concerns\ResolvesFixedPricing` now populates it from the same `FulfillmentType` resolution `Order::isStorePickupOrder()` uses, closing a real gap between documented and actual behavior. ### Changed - `Modules\Core\Order\Filament\Extensions\OrderRefundActionsExtension` renamed to `OrderActionsExtension` — the class now fixes both the refund and capture header actions on the order page, not just refund. - Removed the `lunarphp/stripe` dependency in favour of depending on `stripe/stripe-php` directly. `Modules\Core\Payment\Drivers\StripePaymentDriver` had already replaced every bit of Lunar's own Stripe payment flow (checkout, webhook processing) with its own — all that remained load-bearing from the package was raw API-client access, amount conversion, and a correlation table, none of which are Lunar-specific. Added first-party replacements: `Modules\Core\Payment\Support\ StripeManager`, `Modules\Core\Payment\Models\StripePaymentIntent`, `Modules\Core\Payment\Http\ Middleware\StripeWebhookMiddleware`, and a first-party copy of the vendor's `create_stripe_payment_intents_table` migration (guarded with `Schema::hasTable()`). No behavior change for consuming apps. ## [0.17.4] - 2026-09-15 ### Added - `boboko:catalog:backfill-skus` — one-off Artisan command to generate a SKU (`SKU-P{product_id}-V{variant_id}`) for every `Lunar\Models\ProductVariant` left with a `null` SKU by the earlier Shopify import (the source export's `Variant SKU` column was genuinely blank for these rows, not an importer mapping bug — see `Modules\MigrateImport\Shopify\ ShopifyExportImporter`). Only touches variants missing a SKU; `--dry-run` lists what would change without writing. ## [0.17.3] - 2026-09-15 ### Changed - Removed the `lunarphp/stripe` dependency in favour of depending on `stripe/stripe-php` directly. `Modules\Core\Payment\Drivers\StripePaymentDriver` had already replaced every bit of Lunar's own Stripe payment flow (checkout, webhook processing) with its own — all that remained load-bearing from the package was raw API-client access, amount conversion, and a correlation table, none of which are Lunar-specific. Added first-party replacements: `Modules\Core\Payment\Support\ StripeManager` (API client + `toStripeAmount()`/`fromStripeAmount()`), `Modules\Core\Payment\ Models\StripePaymentIntent` (now with a proper `context` array cast, replacing manual `json_encode`/`json_decode`), and `Modules\Core\Payment\Http\Middleware\ StripeWebhookMiddleware`. Added `database/migrations/..._create_stripe_payment_intents_table.php`, a first-party copy of the vendor migration (guarded with `Schema::hasTable()` so it's a no-op on any environment that already has the table from the vendor package's own earlier migration run, and only actually creates it on a genuinely fresh install). No behavior change for consuming apps — same table, same driver contract, same webhook endpoint. ## [0.17.2] - 2026-09-15 ### Fixed - `Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus` never advanced `Order::status` past `awaiting_payment` on a capture — only `paid`/`paid_at` were written, so a fully captured order could sit indefinitely at "awaiting payment" until a staff member manually clicked "Update Status". Now, on `PaymentCaptured` (not `PaymentAuthorized` — an authorization isn't yet captured funds), `status` advances to the next step in the order's flow (`Modules\Core\Order\Services\OrderStatusFlow::nextOptions()`) — but only when it's still exactly `awaiting_payment`, so a duplicate/delayed capture event never regresses an order staff already moved further. - The backoffice "Capture" action on the order page (Filament) called vendor Lunar's `Lunar\Models\Transaction::capture()` directly, which resolves `Lunar\Facades\Payments` — an entirely separate, unused driver registry — and never dispatched `Modules\Core\Payment\Events\ PaymentCaptured`. This meant a manual capture from the admin panel never ran this app's own payment pipeline at all (including the status-advance fix above). `Modules\Core\Order\Filament\ Extensions\OrderActionsExtension` (renamed from `OrderRefundActionsExtension`, since it now fixes both the refund and capture header actions — see below) now routes capture through `Modules\Core\Payment\Support\TransactionDriverAdapter::capture()`, the same app-level path checkout-time captures use. - `Modules\Core\Payment\Drivers\StripePaymentDriver` never extracted a card's brand/last four digits from Stripe's response, so `Lunar\Models\Transaction::card_type`/`last_four` were always empty and the admin's "Payment of :amount on card ending :last_four" activity-log line rendered with no digits — reproduced on both checkout-time and manual captures. Added `cardMetaFromIntent()`, reading `payment_method_details` off the PaymentIntent's `latest_charge` (same source `lunarphp/stripe`'s own `StoreCharges` uses), populated into `PaymentResult::$meta` from `resultFromIntent()` and `capture()`. `Modules\Core\Order\Services\TransactionRecorder` now maps `meta['card_type']`/`meta['last_four']` onto the `Transaction` row. Only applies to transactions recorded after this change — existing rows are not backfilled. ### Changed - `Modules\Core\Order\Filament\Extensions\OrderRefundActionsExtension` renamed to `OrderActionsExtension` — the class now fixes both the refund and capture header actions on the order page, not just refund, so the old name undersold its scope. ## [0.17.1] - 2026-09-15 ### Fixed - `Modules\Core\Payment\Drivers\StripePaymentDriver::createAndConfirm()` only set `automatic_payment_methods` when no `payment_method` was given — the actual checkout flow always sends one, so it was omitted, and Stripe fell back to whatever payment methods are enabled in the Dashboard and demanded a `return_url` on confirm. Fixed by setting `automatic_payment_methods` unconditionally with `allow_redirects: never` — the storefront's Payment Element already restricts itself to `paymentMethodTypes: ['card']`, so this just tells Stripe the same thing server-side, which drops the `return_url` requirement. ## [0.17.0] - 2026-09-14 ### Added - `Modules\Core\Order\Notifications\OrderPlacedNotification` — an order confirmation email, registered against `Modules\Core\Checkout\Events\OrderPlaced` (fires exactly once per order, regardless of `capture_mode`/driver). Previously only a Stripe (auto-captured) order triggered any placement email at all, via `OrderCapturedNotification` — a different concern (payment confirmation) that happened to fire at the same moment for that one driver; an offline or bank-transfer order got no confirmation whatsoever. Verified live via Mailpit. - `Modules\Core\Order\Listeners\DecrementStockOnOrderPlaced` — also wired to `OrderPlaced`, the first stock decrement anywhere in this codebase (previously nothing wrote to `ProductVariant::stock` as a result of an order at all — overselling was possible). A single atomic `UPDATE ... SET stock = GREATEST(stock - qty, 0)` per variant, not a read-then-write on the Eloquent model, to avoid a lost-update race between two orders decrementing the same variant concurrently. Only touches `purchasable === 'in_stock'` variants on `physical` order lines — `always`/`backorder` variants are deliberately left alone (their stock has no purchasing consequence, decrementing it would just make the column an inaccurate negative number). Also re-triggers Scout reindexing for every affected product, closing the gap `Modules\Core\Catalog\ Services\ProductIndexer`'s own docblock flagged ("nothing currently reindexes a product when an order decrements its stock") — the search index's `in_stock` filter now reflects the change immediately rather than only on the next scheduled reindex. - `Modules\Core\Cart\Services\CartLifecycleService` — the single source of truth for the four cart lifecycle states (Ongoing, Abandoned Cart, Abandoned Checkout, Completed) documented in `docs/cart.md`. Previously `Modules\Core\Cart\Filament\Resources\CartResource\Pages\ListCarts` and `Modules\Core\Cart\Commands\DetectAbandonedCarts` each reimplemented the same query split independently, which is exactly the kind of drift that lets the admin panel and the recovery-email pipeline quietly disagree about what "abandoned" means. Both now build on the same `ongoing()`/`abandonedCarts()`/`abandonedCheckouts()`/`completed()` methods, each taking a `Builder` so callers compose the scope onto whatever base query they already have — Filament's own tab query (search/sort/pagination intact) for `ListCarts`, a bare `Cart::query()` for the command. - `core.cart.unrecoverable_after` config (default `90 days`) — beyond this age, a stale cart stops being treated as an active "Abandoned Cart"/"Abandoned Checkout" at all (excluded from both `CartLifecycleService` methods), rather than staying flagged as an actionable abandonment forever. A 90-day-old (or older) cart's pricing/stock/tax have very likely moved on, so it's not a realistic recovery target — this is about the abandoned-cart pipeline only, not data retention; no rows are deleted or pruned. - `Modules\Core\Cart\Filament\Resources\CartResource\Pages\ViewCart`'s Lines section now shows each line's product thumbnail, name (linking to the product's edit page), and variant options — not just SKU/quantity/price — mirroring Lunar's own order line item display (`OrderItemsTable`). Also added a new Shipping section: the resolved shipping method name (not the bare `acs`-style identifier), destination country, shipping total, and each `shippingBreakdown` line item individually (carrier rate, plus any payment-method fee — see 0.16.3's `ApplyPaymentMethodFee`) so staff can see what makes up the total, not just the sum. Guards around `Lunar\Models\ProductVariant::getDescription()`/`getOption()`: both are typed to return `string` but internally read `translateAttribute()`/`translate()`, which return `null` for a product/option with no attribute data set for the active locale — a real `TypeError` hit live against an existing test-fixture product. Reads the underlying relations directly instead of calling through those methods, falling back to "—" rather than crashing the page. - `Modules\Core\Payment\Drivers\CashOnDeliveryPaymentDriver` — cash-on-delivery/cash-on-pickup was previously wired to `OfflinePaymentDriver`, the same immediate-capture driver as cash-in-hand, which meant a COD order was marked paid the instant it was placed even though no money had actually changed hands. The new driver's `pay()` returns `PaymentResultStatus::Pending` and dispatches nothing, so payment stays unresolved until staff explicitly confirm cash was received (see `Order::paid`/`paid_at` below). A data migration repoints the already-seeded `cash-on-delivery` `PaymentMethod` row to the new driver key. - `Order::paid`/`paid_at` — an entirely independent boolean/timestamp pair tracking payment, settable at any point in an order's lifecycle regardless of fulfillment progress. Exists because cash-on-delivery payment timing has no relationship to the fulfillment sequence at all — a courier might not reconcile cash for weeks after an order is already marked completed. ### Changed - **Order status model, redesigned from scratch.** `Order.status` is a single column again (a same-session 3-axis `payment_status`/`fulfillment_status`/`return_status` design was built, then abandoned before shipping — three independent selects let staff set any combination with no cross-field validation, and didn't map onto how staff actually think about an order: one linear journey, not three simultaneous dials). Now driven by `Modules\Core\Order\Services\ OrderStatusFlow`, a pure transition-table service offering exactly two sequences — carrier and store-pickup (`Order::isStorePickupOrder()`) — never four; payment method (prepaid vs. COD) affects `Order::paid` only, not which sequence an order follows or where it sits in it. The Filament order page's several guided buttons are replaced by three header actions: "Update Status" offers every status in the order's own branch (`OrderStatusFlow::allOptions()`) — not just the guided next step — so staff can also revert to an earlier status (e.g. undoing a mistaken click); it also replaces vendor `ManageOrder`'s own built-in "Update Status" (same action name, previously left in place unintentionally, producing two duplicate buttons), since vendor's writes `status` directly with no audit trail or branch validation. It is a PLAIN status write with no side effects — picking 'dispatched' there does not create a real shipment. "Create Shipment" is its own separate action, visible only for a carrier order at 'ready_for_dispatch' (`OrderFulfillmentService::canCreateShipment()`) — the one action that talks to a real carrier API, so its weight/locker inputs only ever appear for that specific real-world action rather than inside the general-purpose status select for every manual override of 'dispatched'. "Mark Paid" is a third, separate header action — `Order::paid` is independent of `status`, so it doesn't belong bundled into the status select either — visible only when the order's payment method doesn't auto-capture at checkout (currently only cash-on-delivery). New status vocabulary (`awaiting_payment`, `processing`, `ready_for_dispatch`/`ready_for_pickup`, `dispatched`, `delivery_failed`, `picked_up`, `delivered`, `completed`, `return_requested`, `returned`, `partially_refunded`, `refunded`) replaces the old hyphenated 7-value list in `config/lunar/orders.php` — a breaking rename backed by a one-time data migration that maps every existing order onto the new vocabulary (preferring axis-system data where an order was actually moved through it during this session's testing, falling back to the legacy flat status otherwise) and derives `paid` from historical transaction data. (The carrier branch's post-delivery status was initially named `return_window_open`; renamed to `delivered` — same one combined moment, parcel arrived and return window open — via a follow-up migration once the internal name turned out to be a confusing thing for staff to see on an order.) A new "Payment Method" entry on the order summary sidebar (`Order.meta['payment_method']`, falling back to the latest transaction's driver) surfaces which method a shopper actually used, previously shown nowhere on the order page. The order list topbar's tabs (Lunar's own `favourite` config flag) are trimmed to the main-journey statuses only, rather than all twelve — the exception/branch statuses stay reachable via the table's own filter. - "Create Shipment"'s form now branches by carrier (`OrderFulfillmentService::carrierFor()`): - A weight-billed carrier (ACS) gets its weight field pre-filled from the order's own line weights via the new `Modules\Core\Shipping\Support\WeightCalculator` (the same unit-conversion table `AcsRateDriver::totalWeightInKg()` already used for live rate quoting, now shared rather than duplicated) — still staff-editable, not forced. - Box Now ships by compartment size, not weight, so it gets a repeatable list of boxes (one row per physical parcel, each with its own S/M/L size — `ShipmentRequest::$boxes`) instead of the weight field. `BoxNowFulfillmentService::createShipment()` sends one `items` entry per box in a single delivery request and now creates one `Shipment` row per parcel returned (was hardcoded to exactly one box/compartmentSize=1, silently ignoring anything beyond the first parcel) — each row independently trackable/printable/cancellable, linked to its siblings via a shared `meta['delivery_request_id']`. - Box Now's locker field is locked read-only once the shopper's own checkout selection (`$order->shippingAddress->meta['box_now_locker']`) is present — staff can no longer silently redirect a parcel to a different locker than the one the customer picked at checkout; it's only editable for the (current, checkout-UI-less) case where nothing set it yet. - New "Shipments" section on the order page (`Modules\Core\Shipping\Extensions\ OrderShipmentsExtension`, between Transactions and Timeline) — "Create Shipment" previously had no counterpart anywhere to actually see what it created. One entry per `Shipment` record (a multi-box Box Now order shows one entry per parcel), rendered as two inline-labelled lines — carrier + tracking reference, then status + a "Created … · Locker …" helper line — rather than a grid of individually stacked label/value blocks, which reads as a wall of repeated labels once the admin's main content area narrows below Filament's own grid breakpoint (1024px, common with the sidebar open). Two actions per shipment: "Print Label" and "Cancel". Also added `Modules\Core\Shipping\ Http\Controllers\DownloadShipmentLabelController` (short-lived signed URL, same auth model as Lunar's own vendor order-PDF download) — the only other place that called `CarrierFulfillmentInterface::printLabel()` (`ManagePickupManifests`' bulk "Print" action) discarded the returned bytes entirely; this is the first place in the codebase that actually delivers a label to staff. Hit and fixed two bugs while wiring this up: a `TextEntry` with a blank `state('')` skips rendering its `suffixActions()` entirely (Filament's own empty-state branch returns before reaching the actions markup), so the label-download entry needed a real, non-blank value; and the label-download route, registered via `loadRoutesFrom()` with no middleware group, had `SubstituteBindings` never run, so a type-hinted `Shipment $shipment` parameter silently resolved to an empty, non-existent model instead of 404ing — fixed by taking a plain `int $shipment` and looking the record up directly in the controller. - `Modules\Core\Shipping\Enums\TrackingStatus::Failed` — previously unused — is now wired to the new `delivery_failed` status via `Modules\Core\Order\Listeners\ MarkDeliveryFailedOnCarrierCheckpoint`, from which staff can retry dispatch or convert to a return. - Fixed a separate, unrelated bug hit while testing the above: `Lunar\Shipping\Models\ ShippingMethod::macro('isStorePickup', ...)` silently never registered — `Lunar\Base\Traits\ HasModelExtending::__callStatic()` (used by every `Lunar\Base\BaseModel` subclass that doesn't declare its own `macro()`, `ShippingMethod` included) intercepts _every_ unmatched static call and dispatches it as an instance call instead of forwarding to `Macroable`, so `hasMacro()` always returned `false` and every order was silently treated as carrier-fulfilled — including store-pickup ones. `Order::isStorePickupOrder()` (the only caller) now reads `ShippingMethod.data ['fulfillment_type']` directly instead of going through the broken macro. - `CartResource::getEloquentQuery()` no longer filters to carts with a known `user_id`/ `customer_id` — every cart is now listed, guest carts included. Reverses an earlier deliberate exclusion (an anonymous cart has nothing a staff member could click into — no name, no email), which held for that specific concern but not for the resource's other real use: seeing how many carts are ongoing/abandoned right now. Most real storefront traffic never reaches an identified user/customer, so excluding it silently undercounted exactly what `CartLifecycleService` exists to report on. A guest row's Customer/User columns just render "—" (no link) rather than the row being hidden. - `CartLifecycleService::abandonedCarts()` now requires `whereHas('lines')` — an empty cart (created but nothing ever added, e.g. a bot, or a session that never shopped) is no longer counted as "abandoned." There's nothing to recover, so it was a false positive: 9 of 16 carts in the "Abandoned Cart" tab during testing were empty. Removed the now-redundant post-hoc `lines->isEmpty()` skip (and its `with('lines')` eager load) from `DetectAbandonedCarts`, since the query itself excludes them now. - `Modules\Core\Shipping\Models\Manifest` — a real record of "a manifest was issued", replacing the loose `shipments.manifest_reference` string. ACS's own `ACS_Issue_Pickup_List` call returns nothing beyond a `PickupList_No`, so there was previously no way to see which shipments were on a given manifest, or when it was issued, once the moment passed — only per-shipment breadcrumbs. `shipments.manifest_id` (FK, replacing `manifest_reference`) now links each shipment to the `Manifest` row `AcsFulfillmentService::issueManifest()` creates; `ManifestResult::success()` carries the created `Manifest` instead of a bare reference string. A one-time data migration backfills a `Manifest` row per distinct existing `(carrier, manifest_reference)` pair, using the earliest `label_printed_at` (or `updated_at`) among that group as a best-effort `issued_at`, since the real issue time was never recorded anywhere. - Split the standalone `Modules\Core\Shipping\Filament\Pages\ManagePickupManifests` page into two real Filament resources — a bare `Page` has no access to Filament's resource-level pill-tab UI (`HasTabs` is scoped to `ListRecords`), which carrier-by-carrier separation needed: - `Modules\Core\Shipping\Filament\Resources\ShipmentResource` ("Pending Vouchers") — shipments not yet on an issued manifest, one tab per carrier that implements `SupportsManifestBatching` (ACS today; Box Now has no manifest concept at all — courier pickup is booked at shipment-creation time — so it gets no tab). Adding a future carrier with its own manifest endpoints (e.g. Speedex) needs zero UI changes here — tabs are derived from `Shipping::getSupportedDrivers()`, not hardcoded. - `Modules\Core\Shipping\Filament\Resources\ManifestResource` ("Issued Manifests") — lists issued `Manifest` rows (also tabbed by carrier), with a view page and a `ShipmentsRelationManager` showing which shipments a manifest included, each individually reprintable. - Both bulk actions ("Print selected", "Issue Manifest") now catch `Throwable` around the actual carrier API call and surface a Filament notification instead of an unhandled 500 — previously neither had any error handling at all, so an `AcsApiException` (routine against a voucher/pickup date the carrier no longer recognizes) crashed the whole page. - Fixed a bug introduced while building this: `ViewManifest` initially overrode `getRelationManagers()` directly instead of registering `ShipmentsRelationManager` via `ManifestResource::getRelations()` (the actual wiring point — `HasRelationManagers::getAllRelationManagers()` reads from `Resource::getRelations()`, not a page-level override). The override bypassed the trait's own record-check/caching logic and broke the relation manager's Livewire component mount, surfacing as a CSRF-token 419 redirect loop specifically on `/boboko/manifests/{id}`. - "Create Shipment"'s ACS branch gained a "Number of packages" field (`ShipmentRequest:: $packageCount`, already plumbed through to ACS's `Item_Quantity`/`persistMultipartVouchers()` but never exposed in the form) — more than 1 issues a main voucher plus a multi-part sub-voucher per extra package, each its own `Shipment` row sharing the same total weight. The existing weight field was relabeled "Total weight (kg)" to make explicit that ACS bills by one total shipment weight, not per package. ## [0.16.3] - 2026-09-10 ### Fixed - Stripe `createAndConfirm()` built its `PaymentIntent` params with `'automatic_payment_methods' => isset($data['payment_method']) ? null : ['enabled' => true]`. The Stripe PHP SDK does not omit `null`-valued params from `create()` — it serializes them to an empty string (`ApiRequestor::_encodeObjects()` → `Util::utf8(null)`), and Stripe's API rejects an empty `automatic_payment_methods`. Every Stripe charge failed before it started whenever a `payment_method` was supplied (i.e. every real charge in this flow). Fixed by building `$params` conditionally so the key is either omitted entirely or set to `['enabled' => true]`, never `null`. - `Modules\Core\Payment\Filament\Resources\PaymentMethodResource`'s "Driver status" column only flagged a payment method whose driver _class_ no longer resolves (`driver_missing_at`) — it gave no indication when a driver resolves fine but fails `Configurable::isConfigured()` (e.g. Stripe enabled in the DB with no `services.stripe.key` set), which `CheckoutService::getPaymentMethods()` filters out identically. An admin had no way to tell "this method is silently absent at checkout because of missing config" from "everything's fine" at a glance. The same icon column now also reflects `isConfigured()`, with a tooltip distinguishing "driver not found" from "missing required configuration" from "fully configured." - `Modules\Core\Payment\Pipelines\Cart\ApplyCashOnDeliveryFee` (now `ApplyPaymentMethodFee`) had two stacked bugs that together meant a configured payment-method fee (e.g. €5 on Cash on Delivery) never actually reached the cart total: - `PaymentMethod::where(...)->value('data->fee')` silently returned `null` on Postgres — Laravel's query builder does not translate the `->` JSON-path column-selector syntax in `value()`/`pluck()` the way it does inside `where()` clauses, so this resolved to a discarded `stdClass::$data->fee` property access instead of the actual fee. Fixed by loading the model and reading the cast `->data['fee']` attribute instead. - Even with the fee correctly read, adding it directly to `$cart->shippingTotal` didn't survive: `Lunar\Pipelines\Cart\CalculateTax`, which runs later in the same cart-calculation pipeline, unconditionally recomputes `shippingTotal` (and shipping tax) from `$cart->shippingBreakdown`'s item sum — silently discarding anything set only on the plain property. Fixed by adding the fee as its own `Lunar\Base\ValueObjects\Cart\ShippingBreakdownItem` on `shippingBreakdown` instead, so it survives `CalculateTax`'s recompute and is correctly included in shipping tax too. - Also generalized while fixing: the pipeline was hardcoded to the literal type string `cash-on-delivery`. Renamed to `ApplyPaymentMethodFee` and changed it to look up whichever `PaymentMethod` row matches `Cart::meta['payment_method']` and apply its own `data.fee` if present — works for any payment method configured with a fee, not just one specific slug. - `Modules\Core\Checkout\Services\CheckoutService::selectPaymentMethod()` called `$cart->calculate()` after saving the new payment method, but `Lunar\Models\Cart::calculate()` no-ops if the cart instance was already calculated earlier in the same request (`Cart::isCalculated()`) — `Lunar\Managers\CartSessionManager` memoizes one `Cart` instance per request, so this was true on every request where the checkout page's initial render had already calculated the cart. The result: after switching payment methods, the just-saved `meta['payment_method']` change was persisted, but the cart's totals silently kept reflecting whichever method was calculated _first_ in the request — a shopper switching from Cash in Hand to Cash on Delivery would keep seeing Cash in Hand's total, with no COD fee applied, until something else forced a fresh calculation. Fixed by calling `$cart->recalculate()` instead, which forces the pipeline to re-run. ## [0.16.2] - 2026-09-09 ### Fixed - `Lunar\Base\ShippingManifest` is a request-lifetime singleton whose `getOptions()` re-runs the shipping modifier pipeline without ever clearing its `$options` collection first, and whose `addOption()` keeps the first entry per `getIdentifier()` and silently drops any later one. In practice, an option resolved for an earlier shipping address (or cart state) shadowed the correct one after the address/region changed within the same request — e.g. a carrier priced differently across two zones that both match an address would keep quoting the stale zone's price, and `ApplyShipping` would price the cart total off that same stale option. Renamed `Modules\Core\Shipping\Listeners\FlushLivePricingCache` to `Modules\Core\Shipping\Listeners\InvalidateShippingOptions` and had it additionally call `ShippingManifest::clearOptions()`, merged in because both invalidations fire on the exact same event set (`CartLineAdded`, `CartLineUpdated`, `CartLineRemoved`, `CartCleared`, `ShippingAddressSet`) — the only inputs the shipping modifier pipeline depends on. ## [0.16.1] - 2026-09-09 ### Fixed - OTP login page (`resources/views/auth/filament/pages/login.blade.php`) had no visible spacing between the email/OTP input, error text, and buttons following the Filament v3 → v4 upgrade. The view relied on a bare `grid gap-y-4` Tailwind utility class, but since this view ships from the `boboko-core` package rather than a consuming app, that class was never present in any host app's compiled Tailwind output. Replaced with an inline `style` (flex column, `row-gap: 1rem`) so the layout no longer depends on the consuming app's Tailwind content scanning. ## [0.16.0] - 2026-09-08 ### Added - `Modules\Core\Checkout\Services\CheckoutService::setRecoveryConsent(bool $consent): Cart` — the shopper's promotional/abandoned-cart-recovery opt-in, given once during guest checkout and deliberately independent of `setShippingAddress()`/`setBillingAddress()`: consent is a cart-level decision, not tied to any one `CartAddress` — changing which address is on the cart later never resets or re-asks for it. Only an explicit call to this method (the checkbox itself being submitted) ever changes it; calling it again with `false` is how a later opt-out is recorded, per the legal requirement that consent be provable and withdrawable. Stored on `Cart::meta` (interim, per the design this implements — a real column/consent record is the eventual target, tracked as follow-up) as `recovery_consent` (bool), `recovery_consent_at` (ISO 8601, `null` when `false`), and `recovery_consent_policy_version` (`config('legal.privacy_policy_version')` at the moment of consent, so a later dispute is answered from what was actually agreed to). Dispatches new `Modules\Core\Checkout\Events\RecoveryConsentSet`. Newsletter opt-in is explicitly a separate scope — never merged into this flag. - `Modules\Core\Checkout\Services\CheckoutService::initiatePayment()` now requires `bool $termsAccepted` and `string $policyVersion` as mandatory parameters (not optional data a caller might omit) — throws the new `Modules\Core\Checkout\Exceptions\TermsNotAcceptedException` _before_ `Cart::createOrder()` is ever called if `$termsAccepted` is `false`, so an order can never exist without a recorded acceptance (refused, not created-then-flagged). On success, writes `terms_accepted` (`true`), `terms_accepted_at` (ISO 8601), and `terms_accepted_policy_version` onto the created `Order`'s own `meta` — the durable, order-level audit trail for a consumer-contract acceptance dispute, written directly (not via an event/listener) since the `Order` row doesn't exist until `createOrder()` returns. - `Modules\Core\Cart\Commands\DetectAbandonedCarts` — both its `CartAbandoned` and `CheckoutAbandoned` detection queries now require `meta->recovery_consent = true`. A non-consenting cart's abandonment is never dispatched at all (not merely filtered later at whatever future recovery-email send step reads it) — the correct enforcement point per the legal requirement that recovery/marketing sends only ever reach carts that opted in. - `config/legal.php` (merged by a new `Modules\Core\Providers\CheckoutServiceProvider`) — `privacy_policy_version`/`terms_version`, plain `env()`-backed strings bumped by whoever edits the corresponding legal page. Recorded alongside every consent/acceptance rather than read live at dispute time, so what a shopper actually agreed to is answered from the cart/order itself. `CheckoutServiceProvider` itself is new — `Checkout` previously had no dedicated service provider at all (its service/events were resolved/dispatched without one). ## [0.15.0] - 2026-09-07 ### Changed - **Breaking:** `Modules\Core\Payment\Models\PaymentMethod` is now the full DB-instance layer for Payment, same three-layer split (registry / DB instance / cross-cutting config) `Shipping` already has via `ShippingMethod` — see `docs/payments.md`. Every value that used to live in `config('lunar.payments.types.{type}.*')` (`payment_driver`, `capture_mode`, `captured_status`) moves onto the `PaymentMethod` row itself as real columns: `driver` (the new `PaymentDriverRegistry` key — NOT the same as `type`; two rows can share one driver), `name` (admin-facing label, nothing played this role before), `capture_mode`, `captured_status`, `authorized_status`, `position` (admin-controlled ordering, new — reorderable in the Filament table), `driver_missing_at`. `config('lunar.payments.types')` is gone entirely; `config/ payment.php` now holds only `cart_pipeline` (genuinely cross-cutting — every store gets the same pipeline wiring regardless of how many payment methods it configures). - **Breaking:** `Modules\Core\Payment\Services\PaymentDriverResolver` is deleted, replaced by `Modules\Core\Payment\Services\PaymentDriverRegistry` — `register(string $key, string $driverClass)`/`resolve(string $key): ?object`/`all(): array`. Deliberately knows nothing about `PaymentMethod` or the database (mirrors `Lunar\Shipping\Managers\ ShippingManager`'s built-in-methods + `Manager::extend()` split, purpose-built rather than extending `Illuminate\Support\Manager` — Payment's drivers implement several independent capability interfaces at once, not one uniform contract). Built-ins (`OfflinePaymentDriver` as `'offline'`, `StripePaymentDriver` as `'stripe'`) registered in `PaymentServiceProvider::boot()`, exactly how `Shipping::extend('acs', ...)` already works. - **Breaking:** `Modules\Core\Checkout\Services\CheckoutService::getPaymentMethods()` now returns `Illuminate\Support\Collection` (ordered by `position`), not `array`. A method is offered only once three independent checks all pass — `enabled` (admin turned it on), `driver_missing_at` is null (the driver class still exists), and the resolved driver's own `Configurable::isConfigured()` (its runtime requirements are met) — each failure meaning something different to an admin diagnosing why a method isn't showing up. `initiatePayment()` resolves the driver via the selected row's own `driver` column, not `type`. - `Modules\Core\Payment\Filament\Resources\PaymentMethodResource` — `canCreate()`/`canDelete()` now both `true` (previously hardcoded `false`, since a row could only ever be a config-defined type before this release). New create/edit form (`name`, `type`, `driver` — a `Select` populated live from `PaymentDriverRegistry::all()`, `capture_mode`, `captured_status`, `authorized_status`); reorderable table (`->reorderable('position')`); a distinct "Driver status" icon column (separate from the `enabled` toggle) showing whether `driver_missing_at` is set. - `Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus` reads `captured_status`/ `authorized_status` off the `PaymentMethod` row (`where('type', $event->type)`) instead of `config(...)`. - `Modules\Core\Command\InstallLunarCommand::seedPaymentMethods()` no longer iterates `config('lunar.payments.types')` — it seeds exactly one opinionated `cash-on-delivery` starter row, every value a plain literal in the command itself (not sourced from config or the registry — a driver has no business carrying opinions about what its captured order status should be called; that's a merchant decision). Skip-if-exists, same as before. ### Added - `php artisan boboko:payment:sync-drivers` — reconciles every `PaymentMethod` row's `driver` against `PaymentDriverRegistry`, setting `driver_missing_at` when a driver no longer resolves (a package removed, a custom `register()` call deleted) and clearing it automatically if that driver is registered again in a later deploy. Deliberately its own standalone command, meant to run unconditionally on every container start/deploy (Dockerfile entrypoint, alongside `migrate`) — "did the set of registered drivers change" is a deploy-time event, cheap enough to check every time regardless of whether anything actually changed. Verified live: flags a row whose `driver` was manually corrupted, and auto-clears the flag once the driver resolves again. - `docs/payments.md` — new "Registry, DB instance, and cross-cutting config" section: the three-layer split researched against `Shipping`'s own already-existing pattern and three real e-commerce platforms (Shopify, WooCommerce, Medusa.js), the "would a store ever plausibly want two different answers to this" test for deciding config vs. DB-column placement, and the three-check availability chain. - `Modules\Core\Payment\Services\PaymentMethodCache` (`Cache::rememberForever`, same pattern as `Localization\Services\LanguageCache`) + `Modules\Core\Payment\Services\PaymentMethodService` (`create`/`update`/`delete`/`list`) — the single read/write gateway for `PaymentMethod` now used by every Filament resource action (create, edit, edit-fee, delete, the inline `enabled` toggle) instead of the Eloquent model directly, so the cache is invalidated and `PaymentMethodCreated`/`PaymentMethodUpdated`/`PaymentMethodDeleted`/`PaymentMethodsReordered` dispatch on every write, with no exceptions other than Filament's own drag-to-reorder (which does a raw bulk SQL `UPDATE` on the position column directly via `CanReorderRecords`/`reorderTable()`, before `afterReordering()` fires — a confirmed, unavoidable Filament limitation; the reorder hook only clears the cache and dispatches `PaymentMethodsReordered` afterward). `CheckoutService::getPaymentMethods()` and `ApplyResolvedPaymentStatus` both now read through the cache instead of querying `PaymentMethod` directly. - `Modules\Core\Payment\Models\CoreTransaction` (a `Lunar\Models\Transaction` subclass) + `Modules\Core\Payment\Support\TransactionDriverAdapter`, registered via `Lunar\Facades\ModelManifest::replace(Lunar\Models\Contracts\Transaction::class, CoreTransaction::class)` — the same contract-swap mechanism already used elsewhere for `Customer`/`Staff`. Fixes a real crash (`InvalidArgumentException: Driver [cash-on-delivery] not supported`) the first time anything called `$transaction->refund()`/`->capture()`: `Lunar\Models\Transaction::driver()` calls Lunar's own, entirely separate `Lunar\Facades\Payments::driver()` manager, which had never heard of any of this codebase's driver keys. `CoreTransaction::driver()` returns `TransactionDriverAdapter` instead, which resolves the transaction's real `PaymentMethod`/`PaymentDriverRegistry` driver and calls it — Lunar's own admin panel "Refund"/"Capture" buttons now transparently reach the real payment system underneath, including correctly reporting failure (not a silently-faked success) when the resolved driver doesn't implement `SupportsRefunds`/`SupportsCaptures`. - `TransactionDriverAdapter::refundVia(Transaction $transaction, ?string $driverKey, int $amount, ?string $notes = null)` — refund through an explicitly chosen driver, independent of the one the original payment went through (e.g. a cash-on-delivery order refunded via Bank Transfer, which has no notion of the original offline payment at all). The order page's refund action gained a "Refund via" `Select` (every `PaymentDriverRegistry` driver implementing `SupportsRefunds`, defaulting to the transaction's own driver) that routes through this method instead of `Lunar\Models\Transaction::refund()`, whose fixed signature has no room for a driver override. - `Modules\Core\Payment\Drivers\BankTransferPaymentDriver` (registered as `'bank-transfer'`) — manual/attested, same trust model as `OfflinePaymentDriver`: no gateway call, `pay()`/`refund()` decide success immediately on a staff member's say-so. Implements both `SupportsPay` and `SupportsRefunds`; exists specifically so a payment taken through a different method can still be refunded via bank transfer. The admin UI for receiving a payment this way (bank reference, notes, proof-of-transfer upload) is a follow-up — the driver itself is complete and usable via the registry today. - `Modules\Core\Order\Filament\Infolists\TransactionEntry` (swapped in for Lunar's own `Lunar\Admin\Support\Infolists\Components\Transaction` via a new `OrderTransactionsExtension::extendTransactionsRepeatableEntry()` hook) — the order page's transaction cards now also show a note recorded in `Transaction.meta['notes']` when the `notes` column itself is empty. `Order\Services\TransactionRecorder` only ever wrote `notes` from `PaymentResult::$failureReason`, which is never set on a successful result — a manual driver's staff-entered note (e.g. `BankTransferPaymentDriver`'s) was being recorded but had nowhere to render. - `Modules\Core\Payment\Listeners\LogPaymentMethodActivity` — `PaymentMethod` now has an admin activity trail, unlike `Order`/`Transaction`/`Staff` it previously had none. Routes `PaymentMethodCreated`/`Updated`/`Deleted` through the existing `Logging\ActivityLogService` (the same one `Localization\Listeners\LogTranslationActivity` already uses) rather than adding `PaymentMethod` to `Lunar\Base\Traits\LogsActivity`'s generic model-observer logging — `PaymentMethodUpdated::$old`/`PaymentMethodDeleted::$method`'s snapshot already carry more deliberate before/after context than Eloquent's own dirty-attribute diffing would reconstruct. `PaymentMethodsReordered` is deliberately NOT logged — a multi-row position change doesn't fit `ActivityLogService`'s one-`Model`-subject shape, and isn't worth a new method for a low-stakes, purely-cosmetic setting. - New `payment_methods.refunded_status` column + form field (same `Select` pattern as `captured_status`/`authorized_status`) — `ApplyResolvedPaymentStatus` now also reacts to `PaymentRefunded`, so `Order.status` actually changes on a refund; before this, only the _derived_ `Order::paymentStatus()` reflected a refund (reading `transactions` live), while the stored `status` column — what admin filtering, customer emails, etc. actually key off — never moved. Resolves the ORIGINAL payment method for this lookup, not the refund event's own `$type`: a refund routed through a different driver via `refundVia()` (e.g. cash-on-delivery refunded through Bank Transfer) carries the REFUND driver's registry key as `$event->type`, which usually isn't even a real `PaymentMethod.type` — the listener now finds the order's earliest successful `capture`/`intent` transaction instead and reads `refunded_status` off _that_ transaction's own `PaymentMethod` row, since that's the payment the refund is actually reversing. Deliberately no `void_status` yet — a void never moved money, so it doesn't carry the same "the customer needs to see this changed" weight a refund does. ### Fixed - Existing `PaymentMethod` rows seeded before this release (`cash-on-delivery`, `cash-in-hand`) had `driver`/`capture_mode`/`captured_status` all `NULL` after the migration ran — a data backfill was required (not automated by the migration itself) to restore them to a resolvable state; flagged here since a consuming app upgrading past this release needs the same backfill for its own pre-existing rows before `getPaymentMethods()` will offer them again. - `Lunar\Admin\Filament\Resources\OrderResource\Pages\ManageOrder::getRefundAction()`/ `getCaptureAction()` and `OrderItemsTable::getBulkRefundAction()` report a failed refund/capture by calling `$action->failureNotification(...)`, `$action->failure()`, then `$action->halt()` — but `Filament\Actions\Concerns\InteractsWithActions::callMountedAction()` only ever sends that notification from a code path that runs after the action's closure returns normally; `halt()` throws `Filament\Support\Exceptions\Halt`, caught by an earlier `catch` block that rolls back and returns, so the notification was built but never sent — clicking "Refund" on a payment method that genuinely can't be refunded looked like nothing happened at all, no error, no toast. Real, pre-existing Filament/Lunar bug, invisible until this release's `TransactionDriverAdapter` made an honest failure (rather than a hard crash or a silently-faked success) actually reachable. Fixed via new `Modules\Core\Order\Filament\Extensions\OrderRefundActionsExtension`/ `OrderItemsTableExtension`, which wrap the affected actions' closures to send the queued failure notification themselves before re-throwing `Halt`. - `TransactionDriverAdapter::refund()`/`capture()` never included `order_id` in the `$context` passed to the driver, so `Order\Listeners\RecordPaymentTransaction`/`ApplyResolvedPaymentStatus` (both requiring `$context['order_id']`) silently no-op'd for every admin-initiated refund/capture through any driver — no audit `Transaction` row was ever created, regardless of whether the refund/capture itself succeeded. Fixed by passing `$transaction->order_id` through. ## [0.14.0] - 2026-09-03 ### Changed - **Breaking:** `Modules\Core\Catalog\Services\ProductSearchService::search()` now returns `Modules\Core\Catalog\DTOs\ProductListingResult` — the exact same shape `ProductService::list()` already returns — instead of a bare `Illuminate\Database\Eloquent\Collection` of hydrated models with no pagination at all. New signature: `search(string $query, ?ProductFilters $filters = null, ?ProductSort $sort = null, int $perPage = 24, int $page = 1): ProductListingResult`. `->products` is a real `LengthAwarePaginator` of plain, localized indexed-document arrays (not Eloquent models, not Scout's raw response) — a search results page and a category listing page are now interchangeable from a controller's perspective: same DTO, same `ProductCard::fromIndexed()` mapping, same pagination/sort/tag/price-slider handling. `->priceBounds`/`->availableTags` are scoped to the search query itself (delegated to `ProductService::priceSliderBounds()`/`availableTags()`, both of which already accepted a `$query` param for this). - `Modules\Core\Catalog\Services\ProductService::availableTags()` is now `public` (was `private`) and takes an optional `$query` parameter, so `ProductSearchService::search()` can reuse it directly instead of reimplementing the same facet call. ### Added - `Modules\Core\Catalog\Support\ProductDocumentLocalizer` — the per-locale field resolution and raw-Meilisearch-response unwrapping (`withLocalizedFields()`, `hitsFrom()`) extracted out of `ProductService` into its own class, since `ProductSearchService` needed the exact same logic against the exact same kind of document. Both services now depend on this one class instead of `ProductService` owning logic a second service also needed. ## [0.13.0] - 2026-09-03 ### Changed - **Breaking:** `Payment` is now a genuinely standalone module — no direct calls into `Checkout`/`Order`, no reaching into their Eloquent models, communication only via events. The entire old `confirm()`-based flow is gone: `Modules\Core\Payment\Contracts\PaymentDriver` (and the already-stale `Modules\Core\Checkout\Contracts\PaymentDriver` duplicate), `Checkout\Events\PaymentConfirmed`, `Payment\Contracts\InitiatesPayment`, `Payment\DataTransferObjects\PaymentInitiation`, `Payment\Enums\PaymentInitiationMode`, `Payment\Events\PaymentSucceeded`/`PaymentFailed`, `Payment\Events\OrderPaymentStatusResolved`, and `Payment\Exceptions\PaymentNotConfirmedException` are all deleted. This flow was non-functional on `master` before this release — `CheckoutService::confirmPayment()` dispatched an event nothing listened for, so no order was ever placed after payment. - **Breaking:** Every payment operation is now its own explicit, opt-in contract, modeled on how real gateways (Stripe, Mastercard's own gateway, Nexi) actually split these operations — see `docs/payments.md`: `Modules\Core\Payment\Contracts\SupportsPay` (atomic authorize+capture), `SupportsAuthorization` (hold only), `SupportsCaptures` (settle a prior hold), `SupportsVoids` (release a prior hold without settling), `SupportsRefunds` (reverse settled funds), `HandlesPaymentCallback` (resolve an async pay()/authorize() later, from a webhook), and `Configurable` (`isConfigured()`, split out of the old single `PaymentDriver` interface). A driver implements only the operations its gateway actually supports. - **Breaking:** Every amount flowing through these contracts is `Lunar\DataTypes\Price` (Lunar's own bundled minor-unit-value + `Currency` type) — never a bare `int` paired separately with a `Currency`. Each driver converts at its own boundary (e.g. `StripeManager::toStripeAmount()`/`fromStripeAmount()`); `Payment` itself only ever speaks Lunar's `Price`. - **Breaking:** `Modules\Core\Checkout\Services\CheckoutService::placeOrder()` and `confirmPayment()` are both replaced by a single `initiatePayment(string $fingerprint, array $data = []): Modules\Core\Payment\DTOs\PaymentResult`. It creates the draft `Order` (`Cart::createOrder()`, idempotent against an existing draft) and hands off directly to the resolved driver's `pay()`/`authorize()`, per that type's new `config('lunar.payments.types.{type}.capture_mode')` key. `Checkout\Events\OrderPlaced` no longer dispatches from `CheckoutService` — it now fires from `Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus` once a `PaymentCaptured`/`PaymentAuthorized` event actually transitions the order's `placed_at`, since a draft order can now exist well before payment resolves (an async gateway). - `Modules\Core\Payment\Services\PaymentDriverResolver::resolve()` now returns `?object` instead of the deleted `PaymentDriver` interface — a driver implements several independent capability interfaces at once, so a caller does its own `instanceof SupportsPay`/`instanceof SupportsAuthorization` check, the same pattern the capability interfaces themselves are designed around. - `config/payment.php`'s `cash-on-delivery` entry gains `capture_mode` (`'pay'`, since `OfflinePaymentDriver` only implements `SupportsPay`) and `captured_status` (`'payment-offline'`, replacing the previously dead `'authorized' => 'awaiting-payment'` key, which nothing ever read). ### Added - `Modules\Core\Payment\DTOs\PaymentResult` — the one return shape every operation (`pay`, `authorize`, `capture`, `void`, `refund`, `handleCallback`) produces, regardless of gateway: `status` (`Modules\Core\Payment\Enums\PaymentResultStatus`: `Succeeded`/`Failed`/`Pending`), `reference`, `amount` (a `Price`), `failureReason`, `retriable` (real on Stripe/Mastercard's own soft-decline classification, always `false` on Nexi — it has no such signal), `raw` (the untouched gateway response, for audit), `meta`, and `continuation` (see below). - `Modules\Core\Payment\DTOs\PaymentContinuation` / `Modules\Core\Payment\Enums\PaymentContinuationType` — what a caller does next with a `Pending` `PaymentResult`, gateway-agnostically (`Redirect` or `ClientSecret`), so a storefront controller never needs gateway-specific knowledge of e.g. Stripe's own `PaymentIntent` fields to drive a 3-D Secure/redirect continuation. - Eight new events, one terminal pair per operation, replacing the old single `PaymentSucceeded`/`PaymentFailed`: `PaymentAuthorized`/`PaymentAuthorizationFailed`, `PaymentCaptured`/`PaymentCaptureFailed`, `PaymentVoided`/`PaymentVoidFailed`, `PaymentRefunded`/`PaymentRefundFailed`. `PaymentCaptured` is deliberately the same event whether money was taken via `pay()` (one gateway call) or `authorize()`→`capture()` (two calls) — "a payment has been captured" is the same business fact either way. Every event carries `{type, result: PaymentResult, context}` — `context` is an opaque bag the caller hands in and gets back untouched, so `Payment` never needs to know what a `Cart` or `Order` is. - `Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus` (rewired, not new — previously listened to the now-deleted `OrderPaymentStatusResolved`) is the only place an `Order`'s `status` column is written in reaction to a payment outcome: it listens to `PaymentCaptured`/`PaymentAuthorized` directly, reads `$event->context['order_id']`, and resolves the new status from `config('lunar.payments.types.{type}.captured_status')`/`authorized_status`. - `Modules\Core\Payment\Drivers\StripePaymentDriver` rewritten onto the new contracts — implements all six capability interfaces plus `Configurable`. Solves `handleCallback()`'s async-correlation problem (a webhook is a separate HTTP request from the `pay()`/`authorize()` call that started it) the same way `lunarphp/stripe`'s own `StripePaymentType`/`ProcessStripeWebhook` do: real `cart_id`/`order_id` columns on `Lunar\Stripe\Models\StripePaymentIntent`, plus two new columns this driver needs (`context`, `payment_type`) added by a new migration — `database/migrations/2026_09_03_000002_add_context_to_stripe_payment_intents.php`. - `Modules\Core\Payment\Http\Controllers\StripeWebhookController` + `src/Payment/routes/webhooks.php` (`POST /payments/stripe/webhook`, loaded by `PaymentServiceProvider`) — a boboko-owned webhook endpoint, deliberately not `lunarphp/stripe`'s own route (which dispatches into Lunar's own `Payments::driver('stripe')` flow, the flow this driver replaces). Reuses `Lunar\Stripe\Http\Middleware\StripeWebhookMiddleware` and `Stripe\Webhook::constructEvent()` directly — both are genuine Stripe SDK signature verification, safe to reuse without touching the rest of that vendor package's flow. Requires `config('services.stripe.webhooks.lunar')` set in a consuming app; no `stripe` config type entry is added to `config/payment.php` in this release — enabling Stripe for real is a follow-up. - `docs/payments.md` — full design notes: the operation/contract table cross-referenced against Mastercard/Stripe/Nexi's real APIs, why `PaymentResult` normalizes only what every gateway can always provide, the async-correlation pattern, and what's explicitly out of scope (a `Transaction`-writing listener, the `stripe` config entry, frontend Stripe Elements integration). ### Fixed - `Modules\Core\Checkout\Services\CheckoutService::selectPaymentMethod()` crashed (`Call to a member function toArray() on null`) the first time it ran against a cart whose `meta` column was still a genuine SQL `NULL` (any freshly-created cart) — `Cart::$meta`'s `AsArrayObject` cast returns `null`, not an empty array-like object, for a `null` column. Fixed with a null-safe fallback. - `Modules\Core\Shipping\Carriers\Acs\AcsRateDriver`/`BoxNowRateDriver` referenced `Lunar\Shipping\DTOs\ShippingOptionRequest`, a namespace that doesn't exist in the installed `lunarphp/table-rate-shipping` version (the real class is `Lunar\Shipping\DataTransferObjects\ShippingOptionRequest`) — crashed `Illuminate\Support\Manager`'s interface-compatibility check the moment anything touched `ShippingManager::getSupportedDrivers()`, including simply adding a line to a cart (via `Modules\Core\Shipping\Listeners\FlushLivePricingCache`). ## [0.13.1] - 2026-09-03 ### Added - `Modules\Core\Order\Listeners\RecordPaymentTransaction` — writes the `lunar_transactions` row for a successful `PaymentCaptured`/`PaymentAuthorized`/`PaymentVoided`/`PaymentRefunded` event, via a new `Modules\Core\Order\Services\TransactionRecorder` (moved here from `Payment\Services`, and rewritten to take a `PaymentResult` directly instead of the deleted `CaptureResult`/`RefundResult` DTOs — `Payment` never writes to `Order`'s models, `Transaction.order_id` being required is exactly why this lives in `Order`, same reasoning as `ApplyResolvedPaymentStatus`). Closes a real gap introduced in `0.13.0`: `Order::paymentStatus()` (which derives its answer entirely from `$order->transactions`) always resolved to `PaymentStatus::Offline` — its "no transactions at all" fallback — regardless of what actually happened, since nothing had ever written a row. Verified live: a captured offline payment now produces a `type: capture` transaction and `Order::paymentStatus()` correctly resolves to `captured`. ## [0.12.1] - 2026-09-03 ### Fixed - `Modules\Core\MigrateImport\Shopify\ShopifyExportImporter` now attaches a variant's `Variant Image` CSV column to that `ProductVariant`'s own `images()` media pivot (`media_product_variant`, `primary`/`position`). Previously the variant image was never read at all — every image from the CSV, including ones the export clearly scopes to one specific variant, went only into the product's own top-level gallery, so a variant swatch/option change had no way to show its own photo. - `Modules\Core\MigrateImport\Shopify\Resolvers\ProductOptionResolver::resolveOption()` now sets `label` (same value as `name`) when creating a `Lunar\Models\ProductOption`, not just `name`. A `ProductOption` with a null `label` crashes Lunar's own `ProductOptionIndexer::toSearchableArray()` (`foreach()` on `null`) the moment that option gets reindexed — every option created by the importer before this fix has a null `label` and needs a wipe-and-reimport (see `docs/shopify-reimport.md`, new in this release) to pick up the fix, since `firstOrCreate()` never revisits an already-existing row. - `product_reviews.product_id`'s foreign key had no `ON DELETE` clause, so deleting a reviewed `Product` threw a constraint violation instead of the review going with it, unlike every other product-dependent table. New migration adds `cascadeOnDelete()`. ### Added - `Modules\Core\Catalog\Services\ProductIndexer::mapVariant()` now embeds `gtin`, `mpn`, `ean`, `backorder`, `unit_quantity`, `shippable`, `tax_ref`, and `dimensions` (length/width/height/weight/volume, each with `value`+`unit`) on every indexed variant — previously only `id`/`sku`/`stock`/`purchasable`/`options`/`prices`/`media` were embedded, so a search result or filter needing any of these had no way to get at them without a separate Postgres query per variant. - `ProductIndexer::toSearchableArray()` adds a top-level, filterable `skus` field (every variant's SKU, deduplicated) — filtering/matching by SKU no longer requires reaching into the nested `variants` array. - `docs/shopify-reimport.md` — runbook for wiping every imported product (cascading through Lunar so Meilisearch documents go too) and re-running the importer from scratch, needed whenever a fix like the two above only takes effect on newly-created rows. ## [0.12.0] - 2026-09-03 ### Changed - **Breaking:** `Modules\Core\Catalog\Services\ProductService::list()` now returns `Modules\Core\Catalog\DTOs\ProductListingResult` (`->products`: the same `Illuminate\Pagination\LengthAwarePaginator` as before, `->priceBounds`: a new `Modules\Core\Catalog\DTOs\PriceSliderBounds`) instead of returning the paginator directly. A caller doing `$service->list(...)->items()`/`->through(...)` must update to `$service->list(...)->products->items()`/`->through(...)`. This collapses what used to be two separate calls a controller had to orchestrate itself (`list()` for products, `priceRange()` + manual floor/ceil/"is this actually filtered" math for the slider) into one. - **Breaking:** `Modules\Core\Catalog\Services\ProductSearchService::search()`'s signature changed from `search(string $query, ?string $locale = null)` to `search(string $query, ?ProductFilters $filters = null, ?ProductSort $sort = null)` — the `$locale` parameter is gone (see "every configured language, always" below); `$filters`/`$sort` apply the same `Modules\Core\Catalog\Support\ProductFilterBuilder`/`ProductSort::toMeilisearchSort()` semantics `ProductService::list()` already used, so a text search can now be narrowed by price/brand/stock and sorted the same way a category listing can. - `ProductSearchService` now targets every configured store language's fields on every search (`Lunar\Models\Language::all()`), not just the current request locale plus the store's default language. The old `{current, default}` pairing silently stopped catching anything outside those two locales whenever they were equal (a single-language store, or a shopper browsing in the default language) — always searching every configured language closes that gap in both directions. See `docs/product-search.md`. - Extracted `Modules\Core\Catalog\Services\ProductService`'s private `buildFilter()` into a new standalone `Modules\Core\Catalog\Support\ProductFilterBuilder`, so `ProductSearchService` can apply the exact same Meilisearch filter-clause semantics to a text query, instead of reimplementing filter-building a second time. ### Added - `Modules\Core\Catalog\Services\ProductService::priceSliderBounds()` — `priceRange()` rounded to whole currency units (floor/ceil) plus whether the given selected min/max actually narrows it, returned as a `PriceSliderBounds` DTO. Used internally by `list()` now; also callable directly for a caller (e.g. a text-search results page) that needs slider bounds without a full `list()` call. - `Modules\Core\Catalog\Services\ProductService::priceRange()` gained an optional `string $query = ''` parameter, so a caller can scope the price range to a text search's own matches (pass the shopper's search text) instead of always spanning the whole catalog. - `Modules\Core\Catalog\Services\ProductService::random(int $limit)` — random products still scoped to the Meilisearch index's own channel/status visibility, unlike a raw `Product::inRandomOrder()` (which has no notion of that filtering). Meilisearch has no `ORDER BY RANDOM()` equivalent, so this fetches every matching id only (`attributesToRetrieve: ['id']`), shuffles in PHP, then fetches the full localized documents for just the ids picked, restoring the shuffled order afterward (Meilisearch's `id IN [...]` filter doesn't preserve list order on its own). - `Modules\Core\Catalog\Services\ProductService::variantSummaries(array $product)` — the id/price/image of every variant on a product document, for a variant picker/swatch list, without a caller reaching into `$product['variants'][n]['prices'][0]`/`['media'][0]` itself. - `Modules\Core\Catalog\Services\ProductSearchService::search()` now also targets `variants.options.value` — a variant's own option value (e.g. "Κάπτεν Γαμέρικα" on a "Name" option) is matchable by search even when that text never appears in the product's own name or description. - `php artisan lunar:meilisearch:tune-product-search` (`Modules\Core\Command\TuneProductSearchCommand`) — tightens `minWordSizeForTypos` (1 typo only at 8+ characters, 2 typos only at 12+) and disables Meilisearch's `prefixSearch` on the product index. Meilisearch's defaults for both were loose enough to produce bad matches on short Greek words (confirmed the specific case was `prefixSearch`'s default `indexingTime` behavior on a shared word-start, not typo tolerance, via `showMatchesPosition`). Consuming apps should run this after `lunar:meilisearch:setup` whenever the product index needs (re)provisioning — **requires Meilisearch v1.12+** (`prefixSearch` didn't exist as a configurable setting before then). ## [0.11.1] - 2026-09-01 ### Fixed - `Modules\Core\Catalog\Services\RecommendationService::recommend()` built its result with the base `Illuminate\Support\Collection` (`collect()`) instead of `Illuminate\Database\Eloquent\Collection`, even though every element is a `Product` model. `ProductIndexer::toSearchableArray()` calling `->load(['media', 'variants.prices'])` on that result threw `BadMethodCallException: Method Illuminate\Support\Collection::load does not exist` — silently failing every `MakeSearchable` queue job for a saved product (visible only as `FAIL` in the queue log, with the real exception in `storage/logs/laravel.log`). Fixed by having `RecommendationService` accumulate into a real `Eloquent\Collection` from the start. ## [0.11.0] - 2026-09-01 ### Added - `Modules\Core\Catalog\Services\RecommendationService` — computes "related products" for a given product as a configurable, ordered chain of strategies (`config('catalog.recommendation_rules')`), not one hardcoded rule. Tops up from each successive rule until the limit (default 4) is reached or every rule is exhausted — e.g. 3 products from a same-category rule plus 1 from a random fallback — deduplicated across rules so the same product is never returned twice. Ships with `Modules\Core\Catalog\Recommendations\SameCategoryRule` (other products sharing the source product's first collection) and `RandomRule` (the universal fallback, placed last in the default chain). A new rule is just a class implementing `Modules\Core\Catalog\Contracts\RecommendationRule`. Documented in `docs/product-recommendations.md`. - `Modules\Core\Catalog\Services\ProductIndexer` embeds the result directly into each product's own Meilisearch document as `recommendations: [{id, name, price, image}, ...]` (`recommendations.id` filterable) — a product detail page renders its "related products" section with zero extra queries, same reasoning as the existing `collections` field. Deliberately embeds an `id` for the view to build a locale-correct URL from, not a resolved `href` — `product.show` is locale-prefixed, so a URL baked in at index time would only be correct for whichever locale happened to be active during that index run. - `Modules\Core\Catalog\Events\ProductSaved`/`ProductDeleted`, dispatched from `Product::saved()`/`Product::deleted()` in `CatalogServiceProvider` (the latter fires for both a soft delete and a force delete, matching Scout's own `unsearchable()` trigger point) — feed `Modules\Core\Catalog\Listeners\ReindexProductsRecommendingProduct`, which reverse-searches Meilisearch for every product currently recommending the changed/deleted one (`recommendations.id = "..."` — there's no Postgres relation for this, a recommendation only exists inside the index) and re-indexes them via Scout's own `->searchable()`. Product creation is deliberately not hooked into this: a new product not yet appearing as a recommendation elsewhere is an accepted staleness window, the same tradeoff already documented for `in_stock`/`price` — see `docs/product-recommendations.md`. - `CatalogServiceProvider` schedules `lunar:search:index "Lunar\Models\Product" --refresh` daily at 03:00 — a safety net on top of the event-driven reindexing above, covering a newly-created product not yet appearing as a recommendation and any other drift already accepted between reindexes. `--refresh` also re-syncs filterable/sortable index settings, not just documents. ## [0.10.1] - 2026-09-01 ### Added - `Modules\Core\Localization\Services\StorefrontLabels::all()` gains three keys found missing from `3dealer`'s actual `storefront.*` translation usage: `shop.price_min`, `shop.price_max`, `shop.reset` (the price-filter sidebar's min/max labels and its reset link). Picked up by `InstallLunarCommand`'s existing per-key upsert — re-running `lunar:install` on an already-installed store adds only these three rows, leaving everything already seeded or admin-edited untouched. ## [0.10.0] - 2026-08-31 ### Changed - **Breaking:** Upgraded `lunarphp/lunar`, `lunarphp/core`, `lunarphp/stripe`, `lunarphp/table-rate-shipping`, and `lunarphp/search` to `1.5.0`, and `filament/filament` to `v4.12.6` — the first Filament v4 admin panel on this codebase. `lunarphp/filament3-2fa` and `kalnoy/nestedset` are gone, replaced by Filament v4's native two-factor auth and `lunarphp/nestedset`. Ran Filament's automated `filament-v4` migration tool across `src/`, then hand-fixed three bugs it introduced or left behind: a stale `$infolist` variable reference in `CartResource`'s `ViewCart` page (the parameter had been renamed to `$schema` but the body wasn't updated), `ShippingMethodResourceExtension` rewritten to call `getDefaultChildComponents()` (returns `array|Schema`) instead of the type-safe `getChildComponents()` (always `array`), and — unrelated to the tool, but surfaced by the same PHP version bump — `InvalidCouponException`'s `readonly $code` property illegally shadowing the built-in `Exception::$code`, renamed to `$couponCode`. `LunarStaff::addActivitylogExcept()` updated for the renamed `two_factor_secret`/`two_factor_recovery_codes` staff columns (now `app_authentication_secret`/`app_authentication_recovery_codes`; `two_factor_confirmed_at` removed). Consuming apps must run `composer update boboko/core --with-all-dependencies` and `php artisan migrate`. ### Added - `Modules\Core\Checkout\Contracts\PaymentDriver` — the abstraction every payment provider implements: `confirm(Cart $cart, string $type, string $fingerprint, array $data): Order` and `isConfigured(): bool`. A driver only ever calls `CheckoutService::placeOrder()` once it has, by whatever mechanism is native to that gateway, independently confirmed payment — never Lunar's raw `Cart::createOrder()`. This is what lets the storefront checkout sequence stay uniform regardless of which provider is active: set addresses, select shipping, hand off to whichever driver is configured, and the driver decides when (or whether) the order gets created. - `Modules\Core\Payment\Drivers\OfflinePaymentDriver` — shared by every payment type with no real gateway to confirm against (`cash-in-hand`, `cash-on-delivery`): places the order immediately via `CheckoutService::placeOrder()`, then sets the order status from `config("lunar.payments.types.{$type}.authorized")` using the type actually confirmed, not a hardcoded key, since one driver instance serves multiple types. - `Modules\Core\Payment\Drivers\StripePaymentDriver` — a fork, not a decoration, of `lunarphp/stripe`'s `StripePaymentType::authorize()`: that method is `final` and calls `Cart::createOrder()` directly with no seam to redirect into our fingerprint-checked `placeOrder()`, so this class reimplements its logic (intent retrieval, capture-on-policy, status mapping via `UpdateOrderFromIntent`) with that one substitution. Throws the new `Modules\Core\Payment\Exceptions\PaymentNotConfirmedException` on anything short of a genuinely confirmed payment intent — never falls through to placing an order on ambiguity. - `CheckoutService::getPaymentMethods(): array` — every payment type currently offered to the storefront: every key in `config('lunar.payments.types')` that is both administratively enabled (`Modules\Core\Payment\Models\PaymentMethod::enabled`) and whose driver reports `isConfigured()` (e.g. Stripe with no API key set is never offered, regardless of the enabled toggle). `selectPaymentMethod(string $type)` and `confirmPayment(string $type, array $data)` both validate against this list, throwing the new `UnknownPaymentTypeException` for a type that isn't currently offered — re-checked in `confirmPayment()` too, since a type could be disabled between selection and confirmation. - `CheckoutService::selectPaymentMethod()` snapshots `Cart::fingerprint()` into `cart->meta['checkout_fingerprint']` _after_ saving the chosen type and recalculating — the fingerprint has to reflect the final total including any payment-type-specific adjustment (e.g. a COD surcharge), which only exists once `payment_method` is set. `confirmPayment()` reads this stored fingerprint internally rather than taking one as a parameter: a storefront should never need to know `Cart::fingerprint()` exists or capture it at exactly the right moment itself. - `Modules\Core\Payment\Models\PaymentMethod` — one DB row per payment type key (matching `config('lunar.payments.types')`), `enabled` boolean plus a `data` jsonb column (starting with `fee`, the flat cash-on-delivery surcharge) — mirrors Lunar's own `Discount` model (a single jsonb column of keyed settings, not a fixed column per setting or a separate conditions table). Seeded idempotently by `InstallLunarCommand` (skip-if-exists per type, safe to re-run after installing a new payment-provider package), always `enabled: false` — a newly-seeded type shouldn't go live for shoppers before staff have configured and reviewed it. Admin-editable via the new `PaymentMethodResource` (inline enabled toggle, modal fee editor) under Settings. - `ApplyCashOnDeliveryFee` now reads its surcharge from `PaymentMethod` instead of static config, so it's admin-editable without a deploy. ### Fixed - `CashOnDeliveryPaymentDriver` renamed to `OfflinePaymentDriver` and generalized to work for any offline-style type — it previously hardcoded `'cash-on-delivery'` when reading the post-placement order status from config, which would have silently read the wrong type's status the moment a second offline type (`cash-in-hand`) used it. ## [0.9.0] - 2026-08-29 ### Added - `Modules\Core\Cart\Services\CartService` — the boboko-owned API for all cart mutation, wrapping Lunar's `CartSession`/`Cart` primitives: `addLine()`, `updateLine()`, `removeLine()`, `clear()`, `applyCoupon()`/`removeCoupon()` (throws `InvalidCouponException` on an invalid code), and save-for-later (`saveForLater()`/`moveToCart()`/`activeLines()`/`savedLines()`, backed by a `meta.saved_for_later` flag and a new `Modules\Core\Cart\Pipelines\ZeroSavedForLaterPrice` cart-line pipeline step that zeroes a saved line's price so it's excluded from cart totals without being removed). Dispatches 8 real domain events (`CartLineAdded`/`Updated`/`Removed`/`Saved`/`MovedToCart`, `CartCleared`, `CartCouponApplied`/`Removed`) — none have a listener yet, built so a future concern (analytics, recovery) has something to attach to. Documented in `docs/cart.md`. - `Modules\Core\Checkout\Services\CheckoutService` — the boboko-owned API for the checkout stage (address → shipping selection → order placement), sitting between `CartService` and `Order`: `setShippingAddress()`/`setBillingAddress()`, `getShippingOptions()`/`selectShippingOption()` (throws the new `InvalidShippingOptionException` on an identifier that doesn't resolve — previously a silent no-op), and `placeOrder(string $fingerprint)` (the fingerprint is mandatory, not optional — forces re-confirmation via Lunar's own `FingerprintMismatchException` if the cart changed since the shopper last saw its total). Dispatches `ShippingAddressSet`/`BillingAddressSet`/`ShippingOptionSelected`/`OrderPlaced`, each carrying richer, already-resolved payload (e.g. the resolved `ShippingOption`, not just its identifier) than `CartService`'s events. No exception wrapping otherwise — Lunar's own `CartException`/`FingerprintMismatchException` are already the right shape for a storefront to render as form errors. Documented in `docs/checkout.md`. - `Modules\Core\Cart\Filament\Resources\CartResource`'s list view now classifies every cart into one of four states — **Ongoing**, **Abandoned Cart**, **Abandoned Checkout**, **Completed** — instead of the previous two-tab Abandoned/Completed split, distinguishing a cart that never reached checkout from one that has a started-but-unplaced order (mirrors the real distinction in Lunar's own `Cart::scopeActive()`). Abandonment threshold is a fixed, configurable cutoff (`config('core.cart.abandoned_after')`, default 1 hour). Added a customer hyperlink (list column + a "View Customer" header action on the view page, both pointing straight at `customers/{id}` via the plain `customer_id` column, no extra query via the `customer` relation). - `Modules\Core\Cart\Commands\DetectAbandonedCarts` (`boboko:cart:detect-abandoned`, scheduled hourly) dispatches `Modules\Core\Recovery\Events\CartAbandoned`/`CheckoutAbandoned` for carts/checkouts past the abandonment cutoff — detection only, no persistence; a real tracking table is left for when `Recovery` is built as its own concern. Fixed a self-defeating bug from an earlier draft: marking a cart as notified by writing to it bumped `updated_at`, which immediately un-staled it for the next run's own cutoff check. - Merged the `Shipping-Carriers` branch: live carrier rate quoting and fulfillment for **ACS Courier** and **Box Now** (`Modules\Core\Shipping\Carriers\{Acs,BoxNow}`) on top of `lunarphp/table-rate-shipping` — `AcsRateDriver`/`BoxNowRateDriver` (live + static price-break resolution), `AcsFulfillmentService`/`BoxNowFulfillmentService` (shipment creation, label printing, cancellation via the new `Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface`, resolved per-carrier via contextual container binding), `Modules\Core\Shipping\Models\Shipment`/`ShipmentInfo`, `PollShipmentTrackingJob` (scheduled every 30 minutes), `ManagePickupManifests` (Filament page for carrier manifest batching), and an `OrderViewExtension` adding a "Create Shipment" header action to Lunar's order view. Carrier credentials are published config (`config/shippingCarriers/{acs,boxnow}.php`), never committed. - `Modules\Core\Shipping\Concerns\CachesLivePricing` caches a live-priced carrier quote per `(rate, cart)` for 30 minutes — a real, billed API call that's otherwise re-run on every `getShippingOptions()`/`selectShippingOption()` call within the same checkout attempt. `Modules\Core\Shipping\Listeners\FlushLivePricingCache` invalidates it on the only two things that can change a quote: a cart line changing or the shipping address changing (deliberately **not** on order placement — the price the shopper was quoted must still be readable afterwards). Scoped generically to any `SupportsLivePricing` driver, not hardcoded to ACS. - `AcsRateDriver::resolveLivePrice()` now falls back to the rate's own configured static price if the live ACS API call fails (previously: the shipping option silently disappeared from the list on any API error, including a brief outage). `ManageShippingRates` (our Filament subclass of the vendor rates page) now allows a static price to be configured and saved on a "live" rate specifically for this fallback — previously those fields were hidden and discarded on save for any live-priced rate. ### Fixed - Fixed a crash (`Attempt to read property "price" on null`) opening/editing a live-priced shipping rate with no fallback price configured yet — the vendor `ManageShippingRates` page's `afterStateHydrated` callback for the price field had no null-guard for a rate with zero `basePrices`, which is now the routine case for an unconfigured live rate. - Fixed the Filament admin panel's home URL (`/boboko/home`) incorrectly resolving to the Shipping module's `ManagePickupManifests` page instead of the Dashboard — Filament falls back to the first item of the first registered navigation group when no explicit `homeUrl()` is set, and `ManagePickupManifests` had no `navigationGroup`/`navigationSort` of its own. Fixed via explicit `navigationGroup = 'Sales'` / `navigationSort = 100`, placing it after Sales in the nav instead of first overall. ## [0.8.0] - 2026-08-27 ### Added - `Modules\Core\Cart\Filament\Resources\CartResource` gives staff read-only visibility into carts in the Filament admin panel — Lunar ships no cart admin view at all. Scoped to carts with a known `user_id`/`customer_id` (an anonymous guest cart carries no identity staff could act on); list table shows customer/user, line/item counts (via Filament's built-in `->counts()`/`->sum()`, no per-row queries), currency, and last activity. List page has only two tabs, **Abandoned** (default active) and **Completed** — no "All" tab, so the list never runs an unfiltered fetch over the whole table. They key off whether the cart has a **placed** order (`orders.placed_at IS NOT NULL`), not `Cart::completed_at` — that column is declared/cast on the model but never actually written anywhere in Lunar core, so it's not a real signal; "Abandoned" mirrors Lunar's own `Cart::scopeActive()`. `getNavigationBadge()` shows the abandoned-cart count in the sidebar via a single `COUNT(*)` query, no rows loaded. View page runs `$cart->calculate()` once so line/cart totals (plain public properties Lunar never persists) are populated, without paying that cost per row in the list. Documented in `docs/cart.md`. ## [0.7.0] - 2026-08-27 ### Added - `Modules\Core\Catalog\Services\CollectionService` provides category browsing/nav AND single-collection lookup from Meilisearch, mirroring `ProductService` exactly (`list()`, `getById()`, `getBySlug()`, same locale-resolution logic). `Modules\Core\Catalog\Services\CollectionIndexer` extends Lunar's own `Lunar\Search\CollectionIndexer` (which only carried `id`/`name`/`created_at`) to add `parent_id`, `_lft`/`_rgt` (nested-set tree position, filterable/sortable), `collection_group_id`, `slugs`, and `thumbnail`. `Modules\Core\Catalog\DTOs\CollectionFilters` supports `parentId` (children of a specific collection), `groupId`, and `rootOnly` (top-level collections, `parent_id IS NULL` — mutually exclusive with `parentId`). `Modules\Core\Catalog\Enums\CollectionSort` adds `Position` (`_lft:asc`, the recommended default for nav/tree UIs — matches admin arrangement order), `Name`, `Newest`. Must be registered in a consuming app's `config/lunar/search.php` (`Lunar\Models\Collection::class => CollectionIndexer::class`), same as `ProductIndexer`. Documented in `docs/collections.md`. - `Modules\Core\Localization\Services\StorefrontLabels::all()` extracts the default storefront UI label list out of `InstallLunarCommand` into its own class, and adds every previously-missing key (`nav.contact`, `product.description`/`no_image`/`read_more`/`reviews`, `customer_reviews`, `pagination.*`, `review.*`, `shop.*`) that had already been seeded manually in some stores but was absent from the command's own list — bringing the code-side default back in sync with what a real store actually has. `InstallLunarCommand::seedStorefrontLabels()` now does a **per-key upsert** instead of an all-or-nothing "only seed if the group is empty" guard: a key already present in the database (including one an admin has since edited via the Filament **Language Lines** resource) is left untouched, and only missing keys are created via `TranslationService::create()`. This makes it safe to add new keys to `StorefrontLabels::all()` later and re-run `lunar:install` on an already-installed store without either silently skipping the new keys (the old guard's behavior) or reverting an admin's edits back to the hardcoded default. Documented in `docs/localization.md` ("Seeding"). - `Modules\Core\Catalog\Services\CollectionIndexer` adds `ancestors` — `[{id, name}, ...]` ordered root-first (via the newly eager-loaded `ancestors` relation) — so a breadcrumb can render directly from `CollectionService::getById()`/`getBySlug()` with zero extra queries, and `product_count` — how many products are in a collection or any of its descendants, queried from the product Meilisearch index at collection-index time via the same `collection_ids` field `ProductFilters(collectionId:)` filters against. Documented in `docs/collections.md`, including the reindex-ordering gotcha (`product_count` needs the product index reindexed first). - `Modules\Core\Catalog\Services\ProductIndexer` adds a filterable `in_stock` boolean — `true` if any variant currently passes `ProductVariant::canBeFulfilledAtQuantity(1)` (Lunar's own purchasability rule, not a naive `stock > 0` check). `Modules\Core\Catalog\DTOs\ProductFilters` gets a matching `inStockOnly` flag. Reflects stock as of the last reindex only — nothing currently reindexes a product when an order decrements its stock, since that's a cart/checkout concern this doesn't attempt to solve; see `docs/product-listing.md` ("Stock goes stale between orders"). - `Modules\Core\Catalog\Services\ProductService::facets(string $field, ?ProductFilters $filters = null): array` returns Meilisearch facet value counts (e.g. `['Brand A' => 48, 'Brand B' => 135]`) for a discrete-value filterable field, scoped to the given filters. Uses Scout's plain `->options(['facets' => [...]])`, merged directly into the raw Meilisearch query the same way `filter`/`sort` already are — no adoption of Lunar's separate `SearchManager`/`Search` facade needed. `ProductService::priceRange(?ProductFilters $filters = null): array{min, max}` covers the numeric-field case `facets()` explicitly doesn't (`price` would otherwise return one "facet" per exact price) — backed by Meilisearch's `facetStats`, not `facetDistribution`. `priceRange()` always excludes `minPrice`/`maxPrice` from the filter it builds (via a new `$exclude` parameter on the private `buildFilter()`), so a price slider's own bounds don't shrink to whatever range is already selected on it; other filters (`collectionId`, `brand`, `inStockOnly`) still apply normally. Documented in `docs/product-listing.md`. ### Changed - **Breaking:** Renamed the `Product` module to `Catalog`, flattened. Every class under `Modules\Core\Product\*` (`Contracts`, `DTOs`, `Enums`, `Services`, `Observers`, `Filament\Extensions`, `OptionTypes`) now lives under `Modules\Core\Catalog\*` at the same sub-path — e.g. `Modules\Core\Product\Services\ProductService` is now `Modules\Core\Catalog\Services\ProductService`, `Modules\Core\Product\DTOs\ProductFilters` is now `Modules\Core\Catalog\DTOs\ProductFilters`. Class names themselves are unchanged (still `ProductService`, `ProductIndexer`, `ProductFilters`, etc.) — only the namespace/folder moved, to make room for `Collection` as a sibling concern under the same `Catalog` umbrella rather than a disconnected top-level module. Consuming apps must update every `use Modules\Core\Product\...` import and any FQCN reference (`config/lunar/search.php`'s indexer registration, service provider bindings). - **Breaking:** `Modules\Core\Providers\ProductServiceProvider` renamed to `Modules\Core\Providers\CatalogServiceProvider` (composer.json's provider list updated accordingly) — it now only wires `Catalog`-namespace classes (`ProductOptionTypeManager`, `ProductOptionReindexObserver`), so the name follows the same by-concern convention as `LocalizationServiceProvider`/`ReviewServiceProvider`. - **Breaking:** `Modules\Core\Review`'s flat `Extensions/`/`Pages/` folders now nest under `Filament/`, matching the strict per-concern subfolder convention already applied to `Product`(now `Catalog`)/`Localization`. `Modules\Core\Review\Extensions\ProductResourceExtension` is now `Modules\Core\Review\Filament\Extensions\ProductResourceExtension`; `Modules\Core\Review\Pages\ManageProductReviews` is now `Modules\Core\Review\Filament\Pages\ManageProductReviews`. `Modules\Core\Review\Models\ProductReview` is unchanged. - **Breaking:** `ProductFilters(collectionId: ...)` now matches a product in that collection **or any of its descendant collections**, not just direct assignment. Products in a Shopify-imported tree are typically attached only to leaf collections, so filtering strictly on direct assignment meant a parent/root category page (`CollectionFilters(rootOnly: true)`'s results, or any non-leaf collection) always returned zero products even though real products existed several levels down. `Modules\Core\Catalog\Services\ProductIndexer` adds a new filterable `collection_ids` field — every directly-assigned collection's id unioned with all of its ancestors' ids (via the newly eager-loaded `collections.ancestors`) — and `ProductService::buildFilter()` now filters `collectionId` against `collection_ids` instead of the old `collections.id`. The display-only `collections` field (`{id, name}`, direct assignments) is unchanged and no longer filterable. ## [0.6.1] - 2026-08-27 ### Added - `Modules\Core\Product\Contracts\ProductOptionTypeInterface` describes how a category of `Lunar\Models\ProductOption` (e.g. "Color", "Size") behaves — what structured data its values carry in their free-form `meta` jsonb column, and how an admin edits it via Filament — without introducing a new model. Registered via `Modules\Core\Product\Services\ProductOptionTypeManager::get()->register([...])` (a singleton registry, same shape as `Modules\Core\Notification\NotificationRegistry`) from a service provider's `boot()`. An admin then picks one per `ProductOption` from an "Option Type" dropdown on the option's own edit form (added by `Modules\Core\Product\Filament\Extensions\ProductOptionResourceExtension`), stored in `ProductOption::meta['option_type']` — deliberately not tied to the option's `handle`, since a shop's own handle naming shouldn't have to match a type's key. `Modules\Core\Product\Filament\Extensions\ValuesRelationManagerExtension` hooks Lunar's own `ValuesRelationManager` (both extensions via `LunarPanel::extensions()`, registered in `CorePlugin`) to append the resolved type's meta form fields to the stock "Values" tab — no fork of Lunar's classes needed. Ships a reference implementation, `Modules\Core\Product\OptionTypes\ColorOptionType`, registered automatically by the new `Modules\Core\Providers\ProductServiceProvider`. Documented in `docs/product-options.md`. - `Modules\Core\Product\Services\ProductIndexer::mapVariant()` now includes each option's `handle` (alongside its translated name) in a variant's indexed `options[]` — previously only the translated `option`/`value` names and `meta` were indexed, with no stable, locale-independent identifier for which option a value belongs to. - `Modules\Core\Product\Observers\ProductOptionReindexObserver`, wired in the new `Modules\Core\Providers\ProductServiceProvider`, keeps Meilisearch in sync when a `ProductOption` or `ProductOptionValue` is saved or deleted — e.g. picking an Option Type or editing a color's hex. `ProductIndexer::mapVariant()` embeds each option value's `meta` directly into a product's indexed document, but saving the option/value never fires the _product's_ own save events, so without this a changed hex would only reach the index on that product's next unrelated reindex. The observer resolves every `Lunar\Models\Product` whose variants use the changed option (or option value) via the `product_option_value_product_variant` pivot, and calls `->searchable()` on each. ### Changed - **Breaking:** `Modules\Core\Product\Services\ProductIndexer`'s indexed `collections` field is now an array of `{id, name}` objects instead of two parallel arrays (`collections` as bare ID strings, `collection_names` as translated names joined only by array index). `collection_names` is removed. Filtering by collection now targets the nested field `collections.id` (Meilisearch supports filtering on nested object fields), not bare `collections` — `Modules\Core\Product\Services\ProductService::buildFilter()` updated accordingly; `ProductFilters(collectionId: ...)`'s public API is unchanged. Run `php artisan lunar:meilisearch:setup` then `lunar:search:index --refresh` after upgrading (see docs/product-listing.md "Gotchas"). - **Breaking:** `ProductIndexer`'s indexed `review_count`/`average_rating` top-level keys are folded into the existing `reviews` key: `reviews` is now `{items, count, average_rating}` instead of a bare array with `review_count`/`average_rating` as separate sibling keys. `reviews` (the array of review items) moved to `reviews.items`. ## [0.6.0] - 2026-08-27 ### Added - `Modules\Core\Localization\Models\LanguageLine` extends `spatie/laravel-translation-loader`'s `LanguageLine` to fall back to the store's actual default language (`LanguageCache::defaultLocale()`, backed by Lunar's `languages.default` flag) instead of the package's stock behavior of falling back to the static `config('app.fallback_locale')` — the two were previously disconnected, so changing the default language via the Filament **Languages** resource had no effect on which locale an untranslated storefront label silently fell back to. Swapped in automatically via `config('translation-loader.model')` in `LocalizationServiceProvider::register()`; no consuming app changes needed. Documented in `docs/localization.md` ("Fallback locale follows the store's default language"). ### Changed - **Breaking:** `Modules\Core\Catalog\ProductService::list()` now returns a real `Illuminate\Pagination\LengthAwarePaginator` (built from the localized Meilisearch hits) instead of a plain `array{data, meta}` — gives callers normal Laravel pagination behaviour (`$products->links()`, standard JSON serialization) without ever touching Scout's raw `paginateRaw()` response directly. `getById()`/`getBySlug()` are unaffected (still return `?array`). - `ProductService::withLocalizedFields()` (used by `list()`, `getById()`, `getBySlug()`) no longer hardcodes `name`/`description` as the only translated fields — it now reads every `TranslatedText` attribute on `Product` from `Lunar\Base\AttributeManifest` (the same source Lunar's own indexer reads), so a store's own custom translated attributes (e.g. `seo_title`, `seo_description`) are resolved and locale-stripped automatically with no code change here. Raw `{handle}_{locale}` keys (e.g. `name_el`, `seo_title_en`) are now stripped from every returned product, not just `name_*`/`description_*`. - Extracted `Modules\Core\Localization\Services\LanguageCache` (cached read layer over Lunar's `languages` table: `all()`, `defaultLocale()`, `availableLocales()`, `forget()`) out of `LocaleMiddleware`, which previously owned this as private/static methods despite not being middleware-specific behavior. `LocaleMiddleware` now takes `LanguageCache` via constructor injection. `LocaleMiddleware::defaultLocale()`/`forgetLanguagesCache()` (static) are removed — use `app(LanguageCache::class)` or inject `LanguageCache` directly. ### Fixed - `Modules\Core\MigrateImport\JudgeMe\Resolvers\ProductResolver::resolve()` picked whichever `lunar_urls` row matched a slug first, which can be a soft-deleted product left behind by an earlier import batch rather than the current live one — a store can easily end up with more than one `Product` row sharing the same slug across re-imports, since a soft-deleted product's URL row isn't cleaned up. This silently broke every downstream lookup for that handle (e.g. `Modules\Core\MigrateImport\JudgeMe\JudgeMeExportImporter` logging "no product found for handle, skipping review" and dropping the row, even though a live product with that exact handle existed). Rewrote as a join against `lunar_products` — via `Product::query()`, so Eloquent's `SoftDeletes` global scope excludes trashed rows — so only a URL pointing at a live product resolves. - `Modules\Core\Review\Models\ProductReview` had no `registerMediaConversions()` at all, unlike `Product`/`ProductVariant` which get one automatically from Lunar's own `Lunar\Base\StandardMediaDefinitions`. `Modules\Core\Search\ProductIndexer::mapMedia()` is shared across product, variant, and review media and always requests the `small` conversion — the first time a review had an attached image, indexing it threw `Spatie\MediaLibrary\MediaCollections\Exceptions\InvalidConversion`, silently failing the product's `MakeSearchable` queue job (and everything queued after it, since Scout batches). Added a matching `small` conversion (300×300, same fit/border/background as Lunar's standard one) directly on `ProductReview`. ### Breaking - Merged `Modules\Core\Catalog` and `Modules\Core\Search` into a single `Modules\Core\Product` concern, since both existed purely to serve `Product` (browsing/filtering vs. indexing/full-text search — two services, one concern), following a stricter subfolder convention (`Contracts/`, `Enums/`, `Services/`, `DTOs/`, `Models/`, etc. per concern) going forward: - `Modules\Core\Catalog\ProductService` → `Modules\Core\Product\Services\ProductService` - `Modules\Core\Catalog\ProductFilters` → `Modules\Core\Product\DTOs\ProductFilters` - `Modules\Core\Catalog\ProductSort` → `Modules\Core\Product\Enums\ProductSort` - `Modules\Core\Search\ProductIndexer` → `Modules\Core\Product\Services\ProductIndexer` - `Modules\Core\Search\ProductSearchService` → `Modules\Core\Product\Services\ProductSearchService` Consuming apps must update any direct references — notably `config/lunar/search.php`'s `'indexers'` map, which points at `ProductIndexer` by FQCN. `Modules\Core\Catalog\ProductOptionTypeInterface` (in-progress, not yet wired to anything) was deliberately left in place rather than moved. - Reorganized `Modules\Core\Localization` under the same stricter per-concern subfolder convention — `Events/`, `Filament/`, `Listeners/` were already correctly categorized; four loose root files moved into typed buckets by structural role: - `Modules\Core\Localization\LocaleMiddleware` → `Modules\Core\Localization\Middleware\LocaleMiddleware` - `Modules\Core\Localization\LanguageCacheObserver` → `Modules\Core\Localization\Observers\LanguageCacheObserver` - `Modules\Core\Localization\TranslationReader` → `Modules\Core\Localization\Services\TranslationReader` - `Modules\Core\Localization\TranslationService` → `Modules\Core\Localization\Services\TranslationService` `Modules\Core\Localization\Services\LanguageCache` (added earlier in this same unreleased version) already lived at its correct final path — unaffected. The `'locale'` route-middleware alias (registered in `LocalizationServiceProvider`) is unaffected for consuming apps using it by string alias rather than FQCN. ## [0.5.4] - 2026-08-26 ### Added - `Modules\Core\Catalog\ProductService::list()` accepts a `sort` parameter (new `ProductSort` enum: `PriceAsc`, `PriceDesc`, `Newest`), translated into a Meilisearch `sort` clause — `list()` previously had no way to order results, since it always searches with an empty query string and so has no relevance score to fall back on. `Modules\Core\Search\ProductIndexer::getSortableFields()` now also marks `price` sortable (Lunar's base indexer only marks `created_at`/`updated_at`/`skus`/`status`). Requires re-syncing index settings (`php artisan lunar:meilisearch:setup`) on existing stores. Documented in `docs/product-listing.md` ("Sorting"). ## [0.5.3] - 2026-08-26 ### Fixed - `Modules\Core\Search\ProductIndexer::toSearchableArray()` threw `column reference "id" is ambiguous` on Postgres when computing `channel_ids` — `$model->channels()->wherePivot('enabled', true)->pluck('id')` joins `lunar_channels` and `lunar_channelables`, both of which have an `id` column, and the unqualified `pluck('id')` left Postgres unable to resolve which table's column to select (SQLite/MySQL tolerated the ambiguity). Qualified as `pluck('lunar_channels.id')`. ## [0.5.2] - 2026-08-26 ### Fixed - `Modules\Core\Localization\LocaleMiddleware`'s shared view data only ever surfaced a single alternate locale (`altLocale`/`altLocaleUrl`, found via `firstWhere('code', '!=', $current)`) — correct by coincidence for a 2-language store, but silently dropped every locale past the first "other" one found for a 3+ language store, with no error. Replaced with `altLocales`, a collection of every other configured language (`code`, `name`, `url` for the current route each), so a language switcher or `hreflang` tags scale to any number of locales. Documented in `docs/localization.md` ("Shared view data — language switcher and `hreflang` tags"). ## [0.5.1] - 2026-08-25 ### Added - `Modules\Core\Search\ProductIndexer` now indexes `channel_ids` (filterable) — Lunar's base indexer only marks `status` as filterable, not channel assignment, so storefront search couldn't otherwise scope results to products actually assigned and enabled on the current sales channel. Computed from `$product->channels()->wherePivot('enabled', true)`. Ported from an older `Products` branch whose remote had been deleted; the branch's other, now-superseded `ProductIndexer` changes were dropped in favor of the richer indexer already on `master` (collections, price, variants, reviews — see `0.5.0`). ## [0.5.0] - 2026-08-24 ### Added - **`Modules\Core\Catalog\ProductService`**: storefront product listing/filtering (`list()`) and single-product lookup (`getById()`, `getBySlug()`), reading directly from the Meilisearch index rather than the database — one data source, no `->get()` model hydration. Returns plain arrays (not Eloquent models), meant to be called directly from a consuming app's controllers. - `ProductFilters` DTO: optional `collectionId`, `brand`, `minPrice`, `maxPrice`, translated into a Meilisearch `filter` expression. - Listing results are locale-aware: `withLocalizedFields()` resolves `name`/`description` from the indexer's per-locale fields, falling back to the store's default language (via `LocaleMiddleware::defaultLocale()`) when the current locale has no translation yet, instead of rendering blank. - `Modules\Core\Search\ProductIndexer` expanded well beyond its original collection/price additions to carry everything a detail page needs: `id`/`slugs` (filterable — `getById()`/`getBySlug()` resolve purely from the index, no database read), `collection_names`, `tags`, the full media gallery, per-variant data (`sku`, `stock`, `purchasable`, translated option/value names + `meta` for swatches, per-currency prices, variant media), and reviews (`reviews`, `review_count`, `average_rating` — public-safe fields only, `reviewer_email` deliberately excluded). - `Modules\Core\Providers\ReviewServiceProvider` (newly registered): re-indexes a product whenever one of its reviews is created/updated/deleted, since a review write doesn't touch the `Product` row and so never fires the product's own model events. - **`Modules\Core\Search\ProductSearchService`**: locale-aware full-text product search on top of the same Meilisearch index, for use by a storefront's search bar — separate from `ProductService`, which is for browsing/filtering without a query term. - `docs/product-listing.md` and `docs/product-search.md` — usage, full field reference, and design notes for the two services above. - `docs/lunar.md` "Gotchas": three new entries hit while building this — `ProductOption`/`ProductOptionValue::name` isn't `attribute_data` (so `translateAttribute()` silently returns `null` for it), a running `queue:work` process not picking up an edited Scout indexer class, and Scout's `paginateRaw()->items()` on the Meilisearch driver returning the whole raw response rather than a hit list. ### Fixed - The admin login form (`Modules\Core\Auth\Filament\Pages\Login`) had no way back from the OTP-entry step to the email step short of reloading the page. A `back()` method resets to the email step; a "← Back" link/button is shown on the OTP step only. ## [0.4.0] - 2026-08-06 ### Added - **Locale-prefixed routing** (`Modules\Core\Localization\LocaleMiddleware`): a `locale` route-middleware alias, opt-in per shop (not pushed onto the `web` group globally, since admin/Livewire/webhook routes must not be locale-redirected). Reads the first URL segment against Lunar's own `languages` table, sets `App::setLocale()`, and redirects unprefixed/unknown-locale requests to a resolved locale (`Accept-Language` match → default language → first language). Every locale is prefixed, including the default (`/el/...`, `/en/...`), never a bare root — avoids the hreflang/duplicate-content ambiguity of a bare-root default locale. - Language list cached with `Cache::rememberForever()`, invalidated via `Modules\Core\Localization\LanguageCacheObserver` dispatching `LanguageCreated`/`LanguageUpdated`/`LanguageDeleted` events (see below) rather than doing the work itself. - **Language rename safety**: renaming a `Language::code` (e.g. `el` → `gr`) no longer strands existing translations. `MigrateTranslationsForRenamedLanguage` (listening on `LanguageUpdated`) migrates every affected `LanguageLine.text` key from the old code to the new one and flushes both codes' translation caches — closing a real data-loss gap where a rename would otherwise make existing `LanguageLine` translations permanently unreachable. - **Storefront UI label translations**: pulled in `spatie/laravel-translation-loader` (self-registers via Composer package auto-discovery; its loader _extends_ Laravel's file-based `FileLoader` and merges DB translations on top — existing Filament/Lunar vendor `lang/` strings are unaffected). Labels are looked up via Laravel's native `__('storefront.nav.cart')`, kept in its own `storefront` group so nothing collides with Lunar/Filament's own translation groups. - `Modules\Core\Command\InstallLunarCommand` (overriding `lunar:install`) seeds a starter set of ~15 common e-shop labels (`nav.*`, `cart.*`, `product.*`, `auth.*`, `search.*`, English + Greek), idempotently guarded so it's safe on every boot. - `Modules\Core\Localization\TranslationReader::group('storefront')` returns the whole reduced/cached label array for a locale (backed by `LanguageLine`'s own forever-cache) — for sharing to a view as `$labels` or `@json()`-ing to JS, on top of `__()` for single-key Blade lookups. - **Admin UI**: `Modules\Core\Localization\Filament\Resources\LanguageLineResource` (registered in `CorePlugin`) lists/searches/filters `language_lines` and edits each row's `group`, `key`, and one text input per locale currently in `lunar_languages` — locale columns/inputs are generated dynamically from the language list, so a new language needs no resource changes. - **Event-driven writes**: `Modules\Core\Localization\TranslationService` (`create`/`update`/`delete`) is the single write path for `LanguageLine` — the Filament resource's Create/Edit/Delete pages route through it rather than Filament's default direct-model writes. Dispatches `TranslationCreated`/`TranslationUpdated` (carries the full pre-update `{group, key, text}` snapshot, so a bare rename is tracked the same as a text edit)/`TranslationDeleted`, each handled by two listeners: - `FlushTranslationCache` — closes a real gap in `LanguageLine`'s own self-invalidation, which only flushes locales/groups present _after_ a save. Flushes the union of old and new group+locale combinations, so a locale removed from `text`, or a `group`/`key` rename, can't leave a stale cached array behind. - `LogTranslationActivity` — audits every write via the existing `Modules\Core\Logging\ActivityLogService` (`lunar` activity log channel), same `created`/`updated`/`deleted` shape as every other domain write in this project. Properties are flattened with `Arr::dot()` before logging (`text.en`, `text.el` instead of a nested `text` object) since Filament's Activity resource renders `properties` with a flat `KeyValue` field that can't display nested arrays. - `Modules\Core\Providers\LocalizationServiceProvider` — split out of the growing `CoreServiceProvider` (per this project's own "split when a provider does too much" convention) to own all locale/translation middleware, observer, and event-listener registration. ## [0.3.0] - 2026-07-12 ### Added - **Meilisearch product search**: pulled in `lunarphp/search` (Lunar's driver-agnostic search abstraction — `database`/`meilisearch`/`typesense` engines, selectable via Scout's own `SCOUT_DRIVER` config) and `lunarphp/meilisearch`, wiring Meilisearch in as the search engine for products. - `Search\ProductIndexer` overrides Lunar's own indexer to strip HTML tags from string fields (e.g. `name_en`, `description_en`) before they reach the search index — Lunar's default indexer sends raw attribute HTML straight through, which pollutes relevance ranking and highlighting with markup. - Meilisearch itself is treated as app-level infrastructure, not a `boboko-core` concern: the actual Meilisearch container, host port, and master key live in each consuming app's own `docker-compose.yml`/`.env` (e.g. `3dealer`), the same way Postgres and Valkey do — `boboko-core` only declares the PHP package dependency and the indexing code. ## [0.2.0] - 2026-07-10 ### Added - **Product reviews** (`Modules\Core\Review`): a new `ProductReview` model + `product_reviews` table (plain, unprefixed — same convention as `import_mappings`), linked to Lunar's `Product` via a `Product::reviews()` macro (registered in `CorePlugin`, since `Lunar\Models\Product` is a vendor model and can't be edited directly). - **JudgeMe CSV review importer** (`MigrateImport\JudgeMe\JudgeMeExportImporter`), wired into the existing `boboko:migrate:import --source=judgeme --type=export` command: reads a Judge.me review export, resolves each row's `product_handle` to a Lunar product via `Lunar\Models\Url`, and creates/updates `ProductReview` rows idempotently via `import_mappings` (`source=judgeme`, `source_type=review`, keyed on Judge.me's `metaobject_handle`). Rows with no matching product are skipped with a logged warning rather than failing the whole import. - Review images (`picture_urls` in the CSV) are downloaded and stored as real media via Spatie MediaLibrary (`ProductReview::IMAGES_COLLECTION`), not just linked by URL — consistent with how product images are handled. - **Admin UI**: a new "Reviews" sub-navigation page on the product edit screen (`Review\Pages\ManageProductReviews`, wired via `Review\Extensions\ProductResourceExtension`), listing rating/title/reviewer with View, Reply, and Delete actions. The Reply action lets staff write/edit a reply directly from the table, setting `replied_at`. The View modal shows full review detail (body, reviewer email, location, source, dates, reply, downloaded images). ### Fixed - `Shopify\ShopifyExportImporter` never wrote a Lunar `Url` (slug) row for imported products, despite `docs/shopify-import.md` specifying it should — meaning no code outside the importer itself could resolve "which Lunar product has handle X" (only the importer's own private `import_mappings` bookkeeping could). It now creates/updates a default `Url` row (`slug` = Shopify handle) per product on every import, which the new JudgeMe review importer depends on for product resolution. ## [0.1.0] - 2026-07-09 ### Added - **Shipping**: registered Lunar's `lunarphp/table-rate-shipping` plugin (`ShippingPlugin`) directly on `CorePlugin`, so table-rate shipping is available to every consumer app without per-app wiring. - **Product migration/import framework** (`Modules\Core\MigrateImport`): a source-agnostic pipeline for importing a vendor's product catalog into Lunar. - `boboko:migrate:import` Artisan command — interactively prompts for source, type (export/API), and credentials or file path, then dispatches the import as a queued job (`RunMigrateImportJob`) on the default queue. The file-path prompt resolves relative to `storage/app/private/imports/`, so answering e.g. `shopify` picks up the first CSV found in `imports/shopify/` automatically. - `ImportSpec`, `Importer` interface, and `ImporterFactory` (source+type → importer class) as the extension points for future sources (WooCommerce, etc.) and mechanisms (API vs. file export). - `import_mappings` table + `ImportMapping` model: a polymorphic (source, source_type, external_id) → model mapping used by every resolver to make imports idempotent and safely re-runnable. - `DefaultLocale` helper wrapping Lunar's `Language::getDefault()->code`, used anywhere a translatable field needs a locale key, instead of assuming `app()->getLocale()` matches Lunar's configured default. - **Shopify CSV export importer** (`Shopify\ShopifyExportImporter`), the first working source/type combination, verified end-to-end against a real 183-product/693-variant/332-image Shopify export (row counts in the CSV match 1:1 with imported Products/Variants/Media): - `ShopifyCsvReader` + `ProductGroup` group Shopify's flat, repeated-handle CSV rows into one row-group per product (product row, variant rows, image rows). - Ten resolvers under `Shopify\Resolvers`, each responsible for idempotently resolving-or-creating one Lunar entity: `TaxClassResolver`, `ProductTypeResolver` (auto-attaches system attributes to new types), `BrandResolver`, `TagResolver`, `CollectionResolver` (multi-level, multi-collection support via `>`-delimited breadcrumbs), `ProductOptionResolver` (dedupes options/values by slugified name so case variants like "Size"/"size" resolve to one row), `AssetResolver` (Spatie MediaLibrary via `Product::addMedia()`, matches local export images by UUID first, filename fallback), `PriceResolver` (minor-unit conversion per currency), `ImportAttributeResolver` and `ProductAttributeResolver` (custom `cost_per_item`/`seo_title`/`seo_description` attributes, field-type-aware `attribute_data` writing). - `docs/shopify-import.md` — full CSV-to-Lunar field mapping reference and import design notes. - `docs/lunar.md` — new "Gotchas" section documenting non-obvious Lunar behavior hit while building the importer (table-prefix/nested-set race, required `ProductOption.handle`, per-group `Attribute.position`, etc.). - `CONTRIBUTE.md` — local dev setup (path-repo + `bin/dc-core.sh`), and the manual DB-verification workflow used to build this feature. ### Fixed - `ProductOptionResolver` created duplicate `ProductOption`/`ProductOptionValue` rows when the same option or value appeared with different casing across products (e.g. Shopify export rows using both "Size" and "size"), and could create a duplicate value within a single product's own variant rows due to relying on a stale lazy-loaded relation. Both now resolve by normalized (slugified) identity queried fresh from the database. - `boboko:migrate:import` could dispatch an import job with a blank file path (silent no-op failure) if the file-path prompt was answered empty; it now re-prompts until a valid, existing file is given. ## [0.0.1] - 2026-07-03 First release. ### Added - OTP-based authentication built around `User` instead of `Customer` (`UserOtpService`, `UserOtpMail`), replacing the earlier customer-scoped OTP flow. - `UserCreated` event with a `CreateCustomerForUser` listener to provision a Lunar customer automatically when a user is created. - `UserRelationManager` for managing users from the customer resource in the panel. - Stoic image UI component (`resources/views/ui/stoic-image.blade.php`) and its YAML-driven config/service (see `Stoic::class`). - `config/core.php` for module-level configuration. - `AuthServiceProvider` and `CustomerServiceProvider` now register alongside `CoreServiceProvider`. - Migrations: add OTP to `users`, drop OTP from Lunar `customers`, drop `password` from `users`, make `name` nullable on `users` and Lunar `customers`. - `docs/modules.md` documenting module structure. ### Removed - `CustomerOtpMail` and `CustomerOtpService`, superseded by the user-based OTP flow. ### Dependencies - Added explicit `symfony/yaml` requirement (used directly by `Stoic::loadConfig()`).