Files
core/CHANGELOG.md
2026-09-25 13:50:46 +03:00

147 KiB
Raw Permalink Blame History

Changelog

All notable changes to this project will be documented in this file.

The format is based on Keep a Changelog.

[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 uses 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<string, string>. Deliberately knows nothing about PaymentMethod or the database (mirrors Lunar\Shipping\Managers\ ShippingManager's built-in-methods + Manager::extend() split, purpose-built rather than extending Illuminate\Support\Manager — Payment's drivers implement several independent capability interfaces at once, not one uniform contract). Built-ins (OfflinePaymentDriver as 'offline', StripePaymentDriver as 'stripe') registered in PaymentServiceProvider::boot(), exactly how Shipping::extend('acs', ...) already works.
  • Breaking: Modules\Core\Checkout\Services\CheckoutService::getPaymentMethods() now returns Illuminate\Support\Collection<int, PaymentMethod> (ordered by position), not array<string>. A method is offered only once three independent checks all pass — enabled (admin turned it on), driver_missing_at is null (the driver class still exists), and the resolved driver's own Configurable::isConfigured() (its runtime requirements are met) — each failure meaning something different to an admin diagnosing why a method isn't showing up. initiatePayment() resolves the driver via the selected row's own driver column, not type.
  • Modules\Core\Payment\Filament\Resources\PaymentMethodResource — canCreate()/canDelete() now both true (previously hardcoded false, since a row could only ever be a config-defined type before this release). New create/edit form (name, type, driver — a Select populated live from PaymentDriverRegistry::all(), capture_mode, captured_status, authorized_status); reorderable table (->reorderable('position')); a distinct "Driver status" icon column (separate from the enabled toggle) showing whether driver_missing_at is set.
  • Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus reads captured_status/ authorized_status off the PaymentMethod row (where('type', $event->type)) instead of config(...).
  • Modules\Core\Command\InstallLunarCommand::seedPaymentMethods() no longer iterates config('lunar.payments.types') — it seeds exactly one opinionated cash-on-delivery starter row, every value a plain literal in the command itself (not sourced from config or the registry — a driver has no business carrying opinions about what its captured order status should be called; that's a merchant decision). Skip-if-exists, same as before.

Added

  • php artisan boboko:payment:sync-drivers — reconciles every PaymentMethod row's driver against PaymentDriverRegistry, setting driver_missing_at when a driver no longer resolves (a package removed, a custom register() call deleted) and clearing it automatically if that driver is registered again in a later deploy. Deliberately its own standalone command, meant to run unconditionally on every container start/deploy (Dockerfile entrypoint, alongside migrate) — "did the set of registered drivers change" is a deploy-time event, cheap enough to check every time regardless of whether anything actually changed. Verified live: flags a row whose driver was manually corrupted, and auto-clears the flag once the driver resolves again.

  • docs/payments.md — new "Registry, DB instance, and cross-cutting config" section: the three-layer split researched against Shipping's own already-existing pattern and three real e-commerce platforms (Shopify, WooCommerce, Medusa.js), the "would a store ever plausibly want two different answers to this" test for deciding config vs. DB-column placement, and the three-check availability chain.

  • Modules\Core\Payment\Services\PaymentMethodCache (Cache::rememberForever, same pattern as Localization\Services\LanguageCache) + Modules\Core\Payment\Services\PaymentMethodService (create/update/delete/list) — the single read/write gateway for PaymentMethod now used by every Filament resource action (create, edit, edit-fee, delete, the inline enabled toggle) instead of the Eloquent model directly, so the cache is invalidated and PaymentMethodCreated/PaymentMethodUpdated/PaymentMethodDeleted/PaymentMethodsReordered dispatch on every write, with no exceptions other than Filament's own drag-to-reorder (which does a raw bulk SQL UPDATE on the position column directly via CanReorderRecords/reorderTable(), before afterReordering() fires — a confirmed, unavoidable Filament limitation; the reorder hook only clears the cache and dispatches PaymentMethodsReordered afterward). CheckoutService::getPaymentMethods() and ApplyResolvedPaymentStatus both now read through the cache instead of querying PaymentMethod directly.

  • Modules\Core\Payment\Models\CoreTransaction (a Lunar\Models\Transaction subclass) + Modules\Core\Payment\Support\TransactionDriverAdapter, registered via Lunar\Facades\ModelManifest::replace(Lunar\Models\Contracts\Transaction::class, CoreTransaction::class) — the same contract-swap mechanism already used elsewhere for Customer/Staff. Fixes a real crash (InvalidArgumentException: Driver [cash-on-delivery] not supported) the first time anything called $transaction->refund()/->capture(): Lunar\Models\Transaction::driver() calls Lunar's own, entirely separate Lunar\Facades\Payments::driver() manager, which had never heard of any of this codebase's driver keys. CoreTransaction::driver() returns TransactionDriverAdapter instead, which resolves the transaction's real PaymentMethod/PaymentDriverRegistry driver and calls it — Lunar's own admin panel "Refund"/"Capture" buttons now transparently reach the real payment system underneath, including correctly reporting failure (not a silently-faked success) when the resolved driver doesn't implement SupportsRefunds/SupportsCaptures.

  • TransactionDriverAdapter::refundVia(Transaction $transaction, ?string $driverKey, int $amount, ?string $notes = null) — refund through an explicitly chosen driver, independent of the one the original payment went through (e.g. a cash-on-delivery order refunded via Bank Transfer, which has no notion of the original offline payment at all). The order page's refund action gained a "Refund via" Select (every PaymentDriverRegistry driver implementing SupportsRefunds, defaulting to the transaction's own driver) that routes through this method instead of Lunar\Models\Transaction::refund(), whose fixed signature has no room for a driver override.

  • Modules\Core\Payment\Drivers\BankTransferPaymentDriver (registered as 'bank-transfer') — manual/attested, same trust model as OfflinePaymentDriver: no gateway call, pay()/refund() decide success immediately on a staff member's say-so. Implements both SupportsPay and SupportsRefunds; exists specifically so a payment taken through a different method can still be refunded via bank transfer. The admin UI for receiving a payment this way (bank reference, notes, proof-of-transfer upload) is a follow-up — the driver itself is complete and usable via the registry today.

  • Modules\Core\Order\Filament\Infolists\TransactionEntry (swapped in for Lunar's own Lunar\Admin\Support\Infolists\Components\Transaction via a new OrderTransactionsExtension::extendTransactionsRepeatableEntry() hook) — the order page's transaction cards now also show a note recorded in Transaction.meta['notes'] when the notes column itself is empty. Order\Services\TransactionRecorder only ever wrote notes from PaymentResult::$failureReason, which is never set on a successful result — a manual driver's staff-entered note (e.g. BankTransferPaymentDriver's) was being recorded but had nowhere to render.

  • Modules\Core\Payment\Listeners\LogPaymentMethodActivity — PaymentMethod now has an admin activity trail, unlike Order/Transaction/Staff it previously had none. Routes PaymentMethodCreated/Updated/Deleted through the existing Logging\ActivityLogService (the same one Localization\Listeners\LogTranslationActivity already uses) rather than adding PaymentMethod to Lunar\Base\Traits\LogsActivity's generic model-observer logging — PaymentMethodUpdated::$old/PaymentMethodDeleted::$method's snapshot already carry more deliberate before/after context than Eloquent's own dirty-attribute diffing would reconstruct. PaymentMethodsReordered is deliberately NOT logged — a multi-row position change doesn't fit ActivityLogService's one-Model-subject shape, and isn't worth a new method for a low-stakes, purely-cosmetic setting.

  • New payment_methods.refunded_status column + form field (same Select pattern as captured_status/authorized_status) — ApplyResolvedPaymentStatus now also reacts to PaymentRefunded, so Order.status actually changes on a refund; before this, only the derived Order::paymentStatus() reflected a refund (reading transactions live), while the stored status column — what admin filtering, customer emails, etc. actually key off — never moved. Resolves the ORIGINAL payment method for this lookup, not the refund event's own $type: a refund routed through a different driver via refundVia() (e.g. cash-on-delivery refunded through Bank Transfer) carries the REFUND driver's registry key as $event->type, which usually isn't even a real PaymentMethod.type — the listener now finds the order's earliest successful capture/intent transaction instead and reads refunded_status off that transaction's own PaymentMethod row, since that's the payment the refund is actually reversing. Deliberately no void_status yet — a void never moved money, so it doesn't carry the same "the customer needs to see this changed" weight a refund does.

Fixed

  • Existing PaymentMethod rows seeded before this release (cash-on-delivery, cash-in-hand) had driver/capture_mode/captured_status all NULL after the migration ran — a data backfill was required (not automated by the migration itself) to restore them to a resolvable state; flagged here since a consuming app upgrading past this release needs the same backfill for its own pre-existing rows before getPaymentMethods() will offer them again.
  • Lunar\Admin\Filament\Resources\OrderResource\Pages\ManageOrder::getRefundAction()/ getCaptureAction() and OrderItemsTable::getBulkRefundAction() report a failed refund/capture by calling $action->failureNotification(...), $action->failure(), then $action->halt() — but Filament\Actions\Concerns\InteractsWithActions::callMountedAction() only ever sends that notification from a code path that runs after the action's closure returns normally; halt() throws Filament\Support\Exceptions\Halt, caught by an earlier catch block that rolls back and returns, so the notification was built but never sent — clicking "Refund" on a payment method that genuinely can't be refunded looked like nothing happened at all, no error, no toast. Real, pre-existing Filament/Lunar bug, invisible until this release's TransactionDriverAdapter made an honest failure (rather than a hard crash or a silently-faked success) actually reachable. Fixed via new Modules\Core\Order\Filament\Extensions\OrderRefundActionsExtension/ OrderItemsTableExtension, which wrap the affected actions' closures to send the queued failure notification themselves before re-throwing Halt.
  • TransactionDriverAdapter::refund()/capture() never included order_id in the $context passed to the driver, so Order\Listeners\RecordPaymentTransaction/ApplyResolvedPaymentStatus (both requiring $context['order_id']) silently no-op'd for every admin-initiated refund/capture through any driver — no audit Transaction row was ever created, regardless of whether the refund/capture itself succeeded. Fixed by passing $transaction->order_id through.

[0.14.0] - 2026-09-03

Changed

  • Breaking: Modules\Core\Catalog\Services\ProductSearchService::search() now returns Modules\Core\Catalog\DTOs\ProductListingResult — the exact same shape ProductService::list() already returns — instead of a bare Illuminate\Database\Eloquent\Collection<Product> of hydrated models with no pagination at all. New signature: search(string $query, ?ProductFilters $filters = null, ?ProductSort $sort = null, int $perPage = 24, int $page = 1): ProductListingResult. ->products is a real LengthAwarePaginator of plain, localized indexed-document arrays (not Eloquent models, not Scout's raw response) — a search results page and a category listing page are now interchangeable from a controller's perspective: same DTO, same ProductCard::fromIndexed() mapping, same pagination/sort/tag/price-slider handling. ->priceBounds/->availableTags are scoped to the search query itself (delegated to ProductService::priceSliderBounds()/availableTags(), both of which already accepted a $query param for this).
  • Modules\Core\Catalog\Services\ProductService::availableTags() is now public (was private) and takes an optional $query parameter, so ProductSearchService::search() can reuse it directly instead of reimplementing the same facet call.

Added

  • Modules\Core\Catalog\Support\ProductDocumentLocalizer — the per-locale field resolution and raw-Meilisearch-response unwrapping (withLocalizedFields(), hitsFrom()) extracted out of ProductService into its own class, since ProductSearchService needed the exact same logic against the exact same kind of document. Both services now depend on this one class instead of ProductService owning logic a second service also needed.

[0.13.0] - 2026-09-03

Changed

  • 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<Component>), and — unrelated to the tool, but surfaced by the same PHP version bump — InvalidCouponException's readonly $code property illegally shadowing the built-in Exception::$code, renamed to $couponCode. LunarStaff::addActivitylogExcept() updated for the renamed two_factor_secret/two_factor_recovery_codes staff columns (now app_authentication_secret/app_authentication_recovery_codes; two_factor_confirmed_at removed). Consuming apps must run composer update boboko/core --with-all-dependencies and php artisan migrate.

Added

  • Modules\Core\Checkout\Contracts\PaymentDriver — the abstraction every payment provider implements: confirm(Cart $cart, string $type, string $fingerprint, array $data): Order and isConfigured(): bool. A driver only ever calls CheckoutService::placeOrder() once it has, by whatever mechanism is native to that gateway, independently confirmed payment — never Lunar's raw Cart::createOrder(). This is what lets the storefront checkout sequence stay uniform regardless of which provider is active: set addresses, select shipping, hand off to whichever driver is configured, and the driver decides when (or whether) the order gets created.
  • Modules\Core\Payment\Drivers\OfflinePaymentDriver — shared by every payment type with no real gateway to confirm against (cash-in-hand, cash-on-delivery): places the order immediately via CheckoutService::placeOrder(), then sets the order status from config("lunar.payments.types.{$type}.authorized") using the type actually confirmed, not a hardcoded key, since one driver instance serves multiple types.
  • Modules\Core\Payment\Drivers\StripePaymentDriver — a fork, not a decoration, of lunarphp/stripe's StripePaymentType::authorize(): that method is final and calls Cart::createOrder() directly with no seam to redirect into our fingerprint-checked placeOrder(), so this class reimplements its logic (intent retrieval, capture-on-policy, status mapping via UpdateOrderFromIntent) with that one substitution. Throws the new Modules\Core\Payment\Exceptions\PaymentNotConfirmedException on anything short of a genuinely confirmed payment intent — never falls through to placing an order on ambiguity.
  • CheckoutService::getPaymentMethods(): array — every payment type currently offered to the storefront: every key in config('lunar.payments.types') that is both administratively enabled (Modules\Core\Payment\Models\PaymentMethod::enabled) and whose driver reports isConfigured() (e.g. Stripe with no API key set is never offered, regardless of the enabled toggle). selectPaymentMethod(string $type) and confirmPayment(string $type, array $data) both validate against this list, throwing the new UnknownPaymentTypeException for a type that isn't currently offered — re-checked in confirmPayment() too, since a type could be disabled between selection and confirmation.
  • CheckoutService::selectPaymentMethod() snapshots Cart::fingerprint() into cart->meta['checkout_fingerprint'] after saving the chosen type and recalculating — the fingerprint has to reflect the final total including any payment-type-specific adjustment (e.g. a COD surcharge), which only exists once payment_method is set. confirmPayment() reads this stored fingerprint internally rather than taking one as a parameter: a storefront should never need to know Cart::fingerprint() exists or capture it at exactly the right moment itself.
  • 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()).