132 KiB
Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog.
[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 ofShipping\Contracts\DeclaresFulfillmentType.CheckoutService:: getPaymentMethods()excludes a method whose driver disagrees with the cart's currently selected shipping method —OfflinePaymentDriver("pay in store") now requiresstore_pickup,CashOnDeliveryPaymentDriverrequirescarrier. 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 whosePendingresult will never resolve via a later gateway callback (currently onlyCashOnDeliveryPaymentDriver), distinct from a Stripe-stylePendingthat a webhook will still resolve. Handled by the newModules\Core\Order\Listeners\ MarkOrderPlacedOnDeferredPayment, which setsOrder::placed_at, dispatchesOrderPlaced, and advancesstatuspastawaiting_payment— without ever touchingOrder::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 pastawaiting_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 byBoxNowClient'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\ReviewServiceandReviewEvents\ReviewReplied— extracted fromManageProductReviews's inline$record->update(), following the write-then-dispatch pattern used everywhere else.Modules\Core\Catalog\Services\StockService::decrementForOrder()— extracted fromDecrementStockOnOrderPlaced, 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()returnsPending, which dispatched no event at all, so nothing ever setOrder::placed_ator advancedstatus. Fixed byPaymentDeferred/MarkOrderPlacedOnDeferredPaymentabove. - Staff marking a COD order "paid" (
OrderFulfillmentService::markPaid()) flippedOrder::paid/paid_atbut never recorded aTransactionrow — no audit trail, and anything reading$order->transactions(paid-amount displays included) saw nothing. Now records acapturetransaction viaTransactionRecorder, 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/$amountToCollectwere defined on the DTO but no caller ever populated them, permanently dead-coding both carriers' COD branches (AcsFulfillmentService'sCod_Ammount/Cod_Payment_Way, Box Now'samountToBeCollected).OrderViewExtension's "Create Shipment" action now derives both fromOrderStatusFlow::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 —contactNumberis now normalized to+30...before every request. - Creating a Box Now shipment 400'd (
P401/P402) wheneverBOXNOW_ORIGIN_LOCATION_IDor 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\AddAddressdeletes and recreates the cart's shipping address row on every save, wiping whatevermetaa 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 vendorListShippingMethodpage'sCreateActionbuilds its form inline, bypassingShippingMethodResourceExtension's translated-name field entirely;Filament\Pages\ ManageShippingRates's method picker and "Shipping Method" table column also queried/sorted the now-JSONnamecolumn 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 noauthorize()method at all (CashOnDeliveryPaymentDriver), which would have fatally errored at checkout had "authorize" ever been selected — now hidden/non-required unless the driver implementsSupportsAuthorization. A spuriousvalidation.requiredon the translated Name field, and every new Payment Method silently saving atposition0 regardless of the intended "last in the list" default — both traced to the same cause: anAction::schema()modal only dehydrates fields backed by a real form component, sofillForm()'s defaults forname/positionwere computed but never actually reached the saved record. Modules\Core\Auth\Services\UserOtpService::generateAndSend()now dispatchesUserCreatedwhen a newUserrow 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, andDecrementStockOnOrderPlacedstay 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) andstripe_payment_intentswere previously uncovered by any Privacy provider. Pseudonymizes card metadata on erasure (same tax/accounting retention reasoning asOrderDataProvider); 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 — seedocs/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'sactivity_logtable (Modules\Core\Logging\ActivityLogService, plus several Lunar models' nativeLogsActivity) durably retained full PII snapshots inpropertieseven after the real row was erased elsewhere. Redactspropertiesby subject (Customer/Address/CartAddress/OrderAddress/Transaction) on erasure; deliberately never touchescauser_id, which is an actor reference, not PII content. Must run beforeAddressDataProviderinconfig('core.privacy.providers')— see the class's own docblock.ErasureOutcome::Failed— a provider throwing an exception is now a genuine, distinct outcome fromSkipped(a deliberate no-op), surfaced in the erasure report rather than silently aborting the request.
Fixed
PrivacyService::completeErasure()andExportDataSubjectJob::handle()ran every registered provider through a plainarray_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 stuckPending/Failedwith no report at all. Both now catch per-provider (PrivacyService::safeErase(),ExportDataSubjectJob::safeExport()), logging the exception and recordingErasureOutcome::Failed/ProviderExportResult::$errorfor 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 mixederased/failed/erasedreport instead of leaving itPendingforever.CartDataProvider/OrderDataProvidernever covered PII-adjacent keys living inCart.meta/Order.meta/OrderAddress.meta—recovery_consent*,payment_method,checkout_fingerprint(Cart),terms_accepted*(Order), andbox_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()leftotp_code/otp_expires_at/otp_attemptson an otherwise-erasedUserrow. Now cleared alongside name/email.Modules\Core\Privacy\Filament\Resources\DataErasureRequestResource's "Outcome" section referenceddocs/privacy.mddirectly in staff-facing UI text (meaningless to a user with no repo access) and rendered the per-provider report as raw JSON strings via aKeyValueEntry(the wrong component for a list of structured rows). Replaced with a plain-language description and a properRepeatableEntrytable (Data category / Outcome badge / Reason).
Changed
- The 5 existing Privacy providers (
CustomerDataProvider,AddressDataProvider,OrderDataProvider,CartDataProvider,ReviewDataProvider) moved out ofModules\Core\Privacy\Providersinto their owning domain module's ownPrivacy/subdirectory (e.g.Modules\Core\Order\Privacy\OrderDataProvider) —Modules\Core\Privacynow 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 inPrivacydepending on a package that no longer ships in this repo. Seedocs/privacy.mdfor 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(),webguard) — previously it only returned theUsermodel 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 firesIlluminate\Auth\Events\Login, which Lunar's ownLunar\Listeners\CartSessionAuthListener(registered unconditionally in core, no opt-in needed) already reacts to, honoringconfig('lunar.cart.auth_policy')('merge'by default). An earlier draft of this also calledCart::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 setauth_policyto. 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 (mirrorsCartService/CheckoutService's shape):orders()(paginated, placed orders only),order(),addresses(),createAddress()/updateAddress()/deleteAddress(),updateProfile(). Every method is scoped to the given user's ownlatestCustomer()— 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 getsAddressNotFoundException/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 pastmax_attempts, letting an attacker exceed the 5-guess lockout by parallelizing requests instead of sending them serially. Now wrapped in aDB::transaction()withlockForUpdate()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. Nowhash_equals().
[0.17.5] - 2026-09-15
Added
- Greek translations for
Lunar\Models\Country/Statereference data (lang/el/countries.php,lang/el/states.php), keyed by the exact English spellings Lunar's own installer seeds for Greece (fetched fromdata.lunarphp.io/countries+states.json). Loaded vialoadTranslationsFrom()under thecore::namespace — a plain lang file, notModules\Core\Localization's DB-backedTranslationService, 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 throughModules\Core\Payment\Support\ TransactionDriverAdapter::capture(), the same app-level payment pipeline checkout-time captures use, instead of vendor Lunar'sLunar\Models\Transaction::capture()(which resolvedLunar\Facades\Payments, an entirely separate, unused driver registry, and never dispatchedModules\Core\Payment\Events\PaymentCaptured).Modules\Core\Payment\Drivers\StripePaymentDriver::cardMetaFromIntent()— extracts card brand/last-four digits from the Stripe PaymentIntent'slatest_charge, populated intoPaymentResult::$metaand mapped ontoTransaction.card_type/last_fourbyModules\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.nameandLunar\Shipping\Models\ShippingMethod.nameare now locale-keyed JSON columns, rendered in Filament via Lunar's ownLunar\Admin\Support\Forms\Components\ TranslatedText— one input per configuredLanguagerow, same shape/resolution as Product/Collection names. Existing plain-string rows are preserved under the store's default language on migration.ShippingMethodhas no model cast/ModelManifestextension point available (vendor table,Contracts\ShippingMethodexists but is never bound by the package), so its translation is decoded/encoded at the Filament field boundary and via the newModules\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,BoxNowRateDriverboth 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 tocharge_byinstead 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 directdata['fulfillment_type']read inOrder::isStorePickupOrder().
Fixed
Modules\Core\Order\Listeners\ApplyResolvedPaymentStatusnever advancedOrder::statuspastawaiting_paymenton a capture — onlypaid/paid_atwere written, so a fully captured order could sit indefinitely at "awaiting payment" until a staff member manually clicked "Update Status". Now, onPaymentCaptured(notPaymentAuthorized),statusadvances to the next step in the order's flow, but only when it's still exactlyawaiting_payment, so a duplicate/delayed capture event never regresses an order staff already moved further.Lunar\DataTypes\ShippingOption::$collect(the flagdocs/checkout.mddocuments as the mechanism for detecting a pickup option at checkout) was never actually set by any shipping rate driver —Modules\Core\Shipping\Concerns\ResolvesFixedPricingnow populates it from the sameFulfillmentTyperesolutionOrder::isStorePickupOrder()uses, closing a real gap between documented and actual behavior.
Changed
Modules\Core\Order\Filament\Extensions\OrderRefundActionsExtensionrenamed toOrderActionsExtension— the class now fixes both the refund and capture header actions on the order page, not just refund.- Removed the
lunarphp/stripedependency in favour of depending onstripe/stripe-phpdirectly.Modules\Core\Payment\Drivers\StripePaymentDriverhad 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'screate_stripe_payment_intents_tablemigration (guarded withSchema::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 everyLunar\Models\ProductVariantleft with anullSKU by the earlier Shopify import (the source export'sVariant SKUcolumn was genuinely blank for these rows, not an importer mapping bug — seeModules\MigrateImport\Shopify\ ShopifyExportImporter). Only touches variants missing a SKU;--dry-runlists what would change without writing.
[0.17.3] - 2026-09-15
Changed
- Removed the
lunarphp/stripedependency in favour of depending onstripe/stripe-phpdirectly.Modules\Core\Payment\Drivers\StripePaymentDriverhad 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 propercontextarray cast, replacing manualjson_encode/json_decode), andModules\Core\Payment\Http\Middleware\ StripeWebhookMiddleware. Addeddatabase/migrations/..._create_stripe_payment_intents_table.php, a first-party copy of the vendor migration (guarded withSchema::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\ApplyResolvedPaymentStatusnever advancedOrder::statuspastawaiting_paymenton a capture — onlypaid/paid_atwere written, so a fully captured order could sit indefinitely at "awaiting payment" until a staff member manually clicked "Update Status". Now, onPaymentCaptured(notPaymentAuthorized— an authorization isn't yet captured funds),statusadvances to the next step in the order's flow (Modules\Core\Order\Services\OrderStatusFlow::nextOptions()) — but only when it's still exactlyawaiting_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 resolvesLunar\Facades\Payments— an entirely separate, unused driver registry — and never dispatchedModules\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 fromOrderRefundActionsExtension, since it now fixes both the refund and capture header actions — see below) now routes capture throughModules\Core\Payment\Support\TransactionDriverAdapter::capture(), the same app-level path checkout-time captures use. Modules\Core\Payment\Drivers\StripePaymentDrivernever extracted a card's brand/last four digits from Stripe's response, soLunar\Models\Transaction::card_type/last_fourwere 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. AddedcardMetaFromIntent(), readingpayment_method_detailsoff the PaymentIntent'slatest_charge(same sourcelunarphp/stripe's ownStoreChargesuses), populated intoPaymentResult::$metafromresultFromIntent()andcapture().Modules\Core\Order\Services\TransactionRecordernow mapsmeta['card_type']/meta['last_four']onto theTransactionrow. Only applies to transactions recorded after this change — existing rows are not backfilled.
Changed
Modules\Core\Order\Filament\Extensions\OrderRefundActionsExtensionrenamed toOrderActionsExtension— 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 setautomatic_payment_methodswhen nopayment_methodwas 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 areturn_urlon confirm. Fixed by settingautomatic_payment_methodsunconditionally withallow_redirects: never— the storefront's Payment Element already restricts itself topaymentMethodTypes: ['card'], so this just tells Stripe the same thing server-side, which drops thereturn_urlrequirement.
[0.17.0] - 2026-09-14
Added
-
Modules\Core\Order\Notifications\OrderPlacedNotification— an order confirmation email, registered againstModules\Core\Checkout\Events\OrderPlaced(fires exactly once per order, regardless ofcapture_mode/driver). Previously only a Stripe (auto-captured) order triggered any placement email at all, viaOrderCapturedNotification— 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 toOrderPlaced, the first stock decrement anywhere in this codebase (previously nothing wrote toProductVariant::stockas a result of an order at all — overselling was possible). A single atomicUPDATE ... 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 touchespurchasable === 'in_stock'variants onphysicalorder lines —always/backordervariants 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 gapModules\Core\Catalog\ Services\ProductIndexer's own docblock flagged ("nothing currently reindexes a product when an order decrements its stock") — the search index'sin_stockfilter 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 indocs/cart.md. PreviouslyModules\Core\Cart\Filament\Resources\CartResource\Pages\ListCartsandModules\Core\Cart\Commands\DetectAbandonedCartseach 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 sameongoing()/abandonedCarts()/abandonedCheckouts()/completed()methods, each taking aBuilderso callers compose the scope onto whatever base query they already have — Filament's own tab query (search/sort/pagination intact) forListCarts, a bareCart::query()for the command. -
core.cart.unrecoverable_afterconfig (default90 days) — beyond this age, a stale cart stops being treated as an active "Abandoned Cart"/"Abandoned Checkout" at all (excluded from bothCartLifecycleServicemethods), 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 bareacs-style identifier), destination country, shipping total, and eachshippingBreakdownline item individually (carrier rate, plus any payment-method fee — see 0.16.3'sApplyPaymentMethodFee) so staff can see what makes up the total, not just the sum. Guards aroundLunar\Models\ProductVariant::getDescription()/getOption(): both are typed to returnstringbut internally readtranslateAttribute()/translate(), which returnnullfor a product/option with no attribute data set for the active locale — a realTypeErrorhit 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 toOfflinePaymentDriver, 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'spay()returnsPaymentResultStatus::Pendingand dispatches nothing, so payment stays unresolved until staff explicitly confirm cash was received (seeOrder::paid/paid_atbelow). A data migration repoints the already-seededcash-on-deliveryPaymentMethodrow 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.statusis a single column again (a same-session 3-axispayment_status/fulfillment_status/return_statusdesign 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 byModules\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) affectsOrder::paidonly, 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 vendorManageOrder's own built-in "Update Status" (same action name, previously left in place unintentionally, producing two duplicate buttons), since vendor's writesstatusdirectly 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::paidis independent ofstatus, 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 inconfig/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 derivespaidfrom historical transaction data. (The carrier branch's post-delivery status was initially namedreturn_window_open; renamed todelivered— 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 ownfavouriteconfig 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 tableAcsRateDriver::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 oneitemsentry per box in a single delivery request and now creates oneShipmentrow 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 sharedmeta['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.
- A weight-billed carrier (ACS) gets its weight field pre-filled from the order's own line
weights via the new
- 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 perShipmentrecord (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 addedModules\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 calledCarrierFulfillmentInterface::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: aTextEntrywith a blankstate('')skips rendering itssuffixActions()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 vialoadRoutesFrom()with no middleware group, hadSubstituteBindingsnever run, so a type-hintedShipment $shipmentparameter silently resolved to an empty, non-existent model instead of 404ing — fixed by taking a plainint $shipmentand looking the record up directly in the controller. Modules\Core\Shipping\Enums\TrackingStatus::Failed— previously unused — is now wired to the newdelivery_failedstatus viaModules\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 everyLunar\Base\BaseModelsubclass that doesn't declare its ownmacro(),ShippingMethodincluded) intercepts every unmatched static call and dispatches it as an instance call instead of forwarding toMacroable, sohasMacro()always returnedfalseand every order was silently treated as carrier-fulfilled — including store-pickup ones.Order::isStorePickupOrder()(the only caller) now readsShippingMethod.data ['fulfillment_type']directly instead of going through the broken macro. CartResource::getEloquentQuery()no longer filters to carts with a knownuser_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 whatCartLifecycleServiceexists to report on. A guest row's Customer/User columns just render "—" (no link) rather than the row being hidden.CartLifecycleService::abandonedCarts()now requireswhereHas('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-hoclines->isEmpty()skip (and itswith('lines')eager load) fromDetectAbandonedCarts, since the query itself excludes them now.Modules\Core\Shipping\Models\Manifest— a real record of "a manifest was issued", replacing the looseshipments.manifest_referencestring. ACS's ownACS_Issue_Pickup_Listcall returns nothing beyond aPickupList_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, replacingmanifest_reference) now links each shipment to theManifestrowAcsFulfillmentService::issueManifest()creates;ManifestResult::success()carries the createdManifestinstead of a bare reference string. A one-time data migration backfills aManifestrow per distinct existing(carrier, manifest_reference)pair, using the earliestlabel_printed_at(orupdated_at) among that group as a best-effortissued_at, since the real issue time was never recorded anywhere.- Split the standalone
Modules\Core\Shipping\Filament\Pages\ManagePickupManifestspage into two real Filament resources — a barePagehas no access to Filament's resource-level pill-tab UI (HasTabsis scoped toListRecords), 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 implementsSupportsManifestBatching(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 fromShipping::getSupportedDrivers(), not hardcoded.Modules\Core\Shipping\Filament\Resources\ManifestResource("Issued Manifests") — lists issuedManifestrows (also tabbed by carrier), with a view page and aShipmentsRelationManagershowing which shipments a manifest included, each individually reprintable.- Both bulk actions ("Print selected", "Issue Manifest") now catch
Throwablearound the actual carrier API call and surface a Filament notification instead of an unhandled 500 — previously neither had any error handling at all, so anAcsApiException(routine against a voucher/pickup date the carrier no longer recognizes) crashed the whole page. - Fixed a bug introduced while building this:
ViewManifestinitially overrodegetRelationManagers()directly instead of registeringShipmentsRelationManagerviaManifestResource::getRelations()(the actual wiring point —HasRelationManagers::getAllRelationManagers()reads fromResource::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'sItem_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 ownShipmentrow 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 itsPaymentIntentparams with'automatic_payment_methods' => isset($data['payment_method']) ? null : ['enabled' => true]. The Stripe PHP SDK does not omitnull-valued params fromcreate()— it serializes them to an empty string (ApiRequestor::_encodeObjects()→Util::utf8(null)), and Stripe's API rejects an emptyautomatic_payment_methods. Every Stripe charge failed before it started whenever apayment_methodwas supplied (i.e. every real charge in this flow). Fixed by building$paramsconditionally so the key is either omitted entirely or set to['enabled' => true], nevernull. 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 failsConfigurable::isConfigured()(e.g. Stripe enabled in the DB with noservices.stripe.keyset), whichCheckoutService::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 reflectsisConfigured(), with a tooltip distinguishing "driver not found" from "missing required configuration" from "fully configured."Modules\Core\Payment\Pipelines\Cart\ApplyCashOnDeliveryFee(nowApplyPaymentMethodFee) 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 returnednullon Postgres — Laravel's query builder does not translate the->JSON-path column-selector syntax invalue()/pluck()the way it does insidewhere()clauses, so this resolved to a discardedstdClass::$data->feeproperty 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->shippingTotaldidn't survive:Lunar\Pipelines\Cart\CalculateTax, which runs later in the same cart-calculation pipeline, unconditionally recomputesshippingTotal(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 ownLunar\Base\ValueObjects\Cart\ShippingBreakdownItemonshippingBreakdowninstead, so it survivesCalculateTax'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 toApplyPaymentMethodFeeand changed it to look up whicheverPaymentMethodrow matchesCart::meta['payment_method']and apply its owndata.feeif 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, butLunar\Models\Cart::calculate()no-ops if the cart instance was already calculated earlier in the same request (Cart::isCalculated()) —Lunar\Managers\CartSessionManagermemoizes oneCartinstance 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-savedmeta['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\ShippingManifestis a request-lifetime singleton whosegetOptions()re-runs the shipping modifier pipeline without ever clearing its$optionscollection first, and whoseaddOption()keeps the first entry pergetIdentifier()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, andApplyShippingwould price the cart total off that same stale option. RenamedModules\Core\Shipping\Listeners\FlushLivePricingCachetoModules\Core\Shipping\Listeners\InvalidateShippingOptionsand had it additionally callShippingManifest::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 baregrid gap-y-4Tailwind utility class, but since this view ships from theboboko-corepackage rather than a consuming app, that class was never present in any host app's compiled Tailwind output. Replaced with an inlinestyle(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 ofsetShippingAddress()/setBillingAddress(): consent is a cart-level decision, not tied to any oneCartAddress— 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 withfalseis how a later opt-out is recorded, per the legal requirement that consent be provable and withdrawable. Stored onCart::meta(interim, per the design this implements — a real column/consent record is the eventual target, tracked as follow-up) asrecovery_consent(bool),recovery_consent_at(ISO 8601,nullwhenfalse), andrecovery_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 newModules\Core\Checkout\Events\RecoveryConsentSet. Newsletter opt-in is explicitly a separate scope — never merged into this flag.Modules\Core\Checkout\Services\CheckoutService::initiatePayment()now requiresbool $termsAcceptedandstring $policyVersionas mandatory parameters (not optional data a caller might omit) — throws the newModules\Core\Checkout\Exceptions\TermsNotAcceptedExceptionbeforeCart::createOrder()is ever called if$termsAcceptedisfalse, so an order can never exist without a recorded acceptance (refused, not created-then-flagged). On success, writesterms_accepted(true),terms_accepted_at(ISO 8601), andterms_accepted_policy_versiononto the createdOrder's ownmeta— the durable, order-level audit trail for a consumer-contract acceptance dispute, written directly (not via an event/listener) since theOrderrow doesn't exist untilcreateOrder()returns.Modules\Core\Cart\Commands\DetectAbandonedCarts— both itsCartAbandonedandCheckoutAbandoneddetection queries now requiremeta->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 newModules\Core\Providers\CheckoutServiceProvider) —privacy_policy_version/terms_version, plainenv()-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.CheckoutServiceProvideritself is new —Checkoutpreviously 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\PaymentMethodis now the full DB-instance layer for Payment, same three-layer split (registry / DB instance / cross-cutting config)Shippingalready has viaShippingMethod— seedocs/payments.md. Every value that used to live inconfig('lunar.payments.types.{type}.*')(payment_driver,capture_mode,captured_status) moves onto thePaymentMethodrow itself as real columns:driver(the newPaymentDriverRegistrykey — NOT the same astype; 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.phpnow holds onlycart_pipeline(genuinely cross-cutting — every store gets the same pipeline wiring regardless of how many payment methods it configures). - Breaking:
Modules\Core\Payment\Services\PaymentDriverResolveris deleted, replaced byModules\Core\Payment\Services\PaymentDriverRegistry—register(string $key, string $driverClass)/resolve(string $key): ?object/all(): array<string, string>. Deliberately knows nothing aboutPaymentMethodor the database (mirrorsLunar\Shipping\Managers\ ShippingManager's built-in-methods +Manager::extend()split, purpose-built rather than extendingIlluminate\Support\Manager— Payment's drivers implement several independent capability interfaces at once, not one uniform contract). Built-ins (OfflinePaymentDriveras'offline',StripePaymentDriveras'stripe') registered inPaymentServiceProvider::boot(), exactly howShipping::extend('acs', ...)already works. - Breaking:
Modules\Core\Checkout\Services\CheckoutService::getPaymentMethods()now returnsIlluminate\Support\Collection<int, PaymentMethod>(ordered byposition), notarray<string>. A method is offered only once three independent checks all pass —enabled(admin turned it on),driver_missing_atis null (the driver class still exists), and the resolved driver's ownConfigurable::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 owndrivercolumn, nottype. Modules\Core\Payment\Filament\Resources\PaymentMethodResource—canCreate()/canDelete()now bothtrue(previously hardcodedfalse, since a row could only ever be a config-defined type before this release). New create/edit form (name,type,driver— aSelectpopulated live fromPaymentDriverRegistry::all(),capture_mode,captured_status,authorized_status); reorderable table (->reorderable('position')); a distinct "Driver status" icon column (separate from theenabledtoggle) showing whetherdriver_missing_atis set.Modules\Core\Order\Listeners\ApplyResolvedPaymentStatusreadscaptured_status/authorized_statusoff thePaymentMethodrow (where('type', $event->type)) instead ofconfig(...).Modules\Core\Command\InstallLunarCommand::seedPaymentMethods()no longer iteratesconfig('lunar.payments.types')— it seeds exactly one opinionatedcash-on-deliverystarter 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 everyPaymentMethodrow'sdriveragainstPaymentDriverRegistry, settingdriver_missing_atwhen a driver no longer resolves (a package removed, a customregister()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, alongsidemigrate) — "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 whosedriverwas 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 againstShipping'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 asLocalization\Services\LanguageCache) +Modules\Core\Payment\Services\PaymentMethodService(create/update/delete/list) — the single read/write gateway forPaymentMethodnow used by every Filament resource action (create, edit, edit-fee, delete, the inlineenabledtoggle) instead of the Eloquent model directly, so the cache is invalidated andPaymentMethodCreated/PaymentMethodUpdated/PaymentMethodDeleted/PaymentMethodsReordereddispatch on every write, with no exceptions other than Filament's own drag-to-reorder (which does a raw bulk SQLUPDATEon the position column directly viaCanReorderRecords/reorderTable(), beforeafterReordering()fires — a confirmed, unavoidable Filament limitation; the reorder hook only clears the cache and dispatchesPaymentMethodsReorderedafterward).CheckoutService::getPaymentMethods()andApplyResolvedPaymentStatusboth now read through the cache instead of queryingPaymentMethoddirectly. -
Modules\Core\Payment\Models\CoreTransaction(aLunar\Models\Transactionsubclass) +Modules\Core\Payment\Support\TransactionDriverAdapter, registered viaLunar\Facades\ModelManifest::replace(Lunar\Models\Contracts\Transaction::class, CoreTransaction::class)— the same contract-swap mechanism already used elsewhere forCustomer/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 separateLunar\Facades\Payments::driver()manager, which had never heard of any of this codebase's driver keys.CoreTransaction::driver()returnsTransactionDriverAdapterinstead, which resolves the transaction's realPaymentMethod/PaymentDriverRegistrydriver 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 implementSupportsRefunds/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(everyPaymentDriverRegistrydriver implementingSupportsRefunds, defaulting to the transaction's own driver) that routes through this method instead ofLunar\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 asOfflinePaymentDriver: no gateway call,pay()/refund()decide success immediately on a staff member's say-so. Implements bothSupportsPayandSupportsRefunds; 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 ownLunar\Admin\Support\Infolists\Components\Transactionvia a newOrderTransactionsExtension::extendTransactionsRepeatableEntry()hook) — the order page's transaction cards now also show a note recorded inTransaction.meta['notes']when thenotescolumn itself is empty.Order\Services\TransactionRecorderonly ever wrotenotesfromPaymentResult::$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—PaymentMethodnow has an admin activity trail, unlikeOrder/Transaction/Staffit previously had none. RoutesPaymentMethodCreated/Updated/Deletedthrough the existingLogging\ActivityLogService(the same oneLocalization\Listeners\LogTranslationActivityalready uses) rather than addingPaymentMethodtoLunar\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.PaymentMethodsReorderedis deliberately NOT logged — a multi-row position change doesn't fitActivityLogService's one-Model-subject shape, and isn't worth a new method for a low-stakes, purely-cosmetic setting. -
New
payment_methods.refunded_statuscolumn + form field (sameSelectpattern ascaptured_status/authorized_status) —ApplyResolvedPaymentStatusnow also reacts toPaymentRefunded, soOrder.statusactually changes on a refund; before this, only the derivedOrder::paymentStatus()reflected a refund (readingtransactionslive), while the storedstatuscolumn — 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 viarefundVia()(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 realPaymentMethod.type— the listener now finds the order's earliest successfulcapture/intenttransaction instead and readsrefunded_statusoff that transaction's ownPaymentMethodrow, since that's the payment the refund is actually reversing. Deliberately novoid_statusyet — 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
PaymentMethodrows seeded before this release (cash-on-delivery,cash-in-hand) haddriver/capture_mode/captured_statusallNULLafter 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 beforegetPaymentMethods()will offer them again. Lunar\Admin\Filament\Resources\OrderResource\Pages\ManageOrder::getRefundAction()/getCaptureAction()andOrderItemsTable::getBulkRefundAction()report a failed refund/capture by calling$action->failureNotification(...),$action->failure(), then$action->halt()— butFilament\Actions\Concerns\InteractsWithActions::callMountedAction()only ever sends that notification from a code path that runs after the action's closure returns normally;halt()throwsFilament\Support\Exceptions\Halt, caught by an earliercatchblock 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'sTransactionDriverAdaptermade an honest failure (rather than a hard crash or a silently-faked success) actually reachable. Fixed via newModules\Core\Order\Filament\Extensions\OrderRefundActionsExtension/OrderItemsTableExtension, which wrap the affected actions' closures to send the queued failure notification themselves before re-throwingHalt.TransactionDriverAdapter::refund()/capture()never includedorder_idin the$contextpassed to the driver, soOrder\Listeners\RecordPaymentTransaction/ApplyResolvedPaymentStatus(both requiring$context['order_id']) silently no-op'd for every admin-initiated refund/capture through any driver — no auditTransactionrow was ever created, regardless of whether the refund/capture itself succeeded. Fixed by passing$transaction->order_idthrough.
[0.14.0] - 2026-09-03
Changed
- Breaking:
Modules\Core\Catalog\Services\ProductSearchService::search()now returnsModules\Core\Catalog\DTOs\ProductListingResult— the exact same shapeProductService::list()already returns — instead of a bareIlluminate\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.->productsis a realLengthAwarePaginatorof 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, sameProductCard::fromIndexed()mapping, same pagination/sort/tag/price-slider handling.->priceBounds/->availableTagsare scoped to the search query itself (delegated toProductService::priceSliderBounds()/availableTags(), both of which already accepted a$queryparam for this). Modules\Core\Catalog\Services\ProductService::availableTags()is nowpublic(wasprivate) and takes an optional$queryparameter, soProductSearchService::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 ofProductServiceinto its own class, sinceProductSearchServiceneeded the exact same logic against the exact same kind of document. Both services now depend on this one class instead ofProductServiceowning logic a second service also needed.
[0.13.0] - 2026-09-03
Changed
- Breaking:
Paymentis now a genuinely standalone module — no direct calls intoCheckout/Order, no reaching into their Eloquent models, communication only via events. The entire oldconfirm()-based flow is gone:Modules\Core\Payment\Contracts\PaymentDriver(and the already-staleModules\Core\Checkout\Contracts\PaymentDriverduplicate),Checkout\Events\PaymentConfirmed,Payment\Contracts\InitiatesPayment,Payment\DataTransferObjects\PaymentInitiation,Payment\Enums\PaymentInitiationMode,Payment\Events\PaymentSucceeded/PaymentFailed,Payment\Events\OrderPaymentStatusResolved, andPayment\Exceptions\PaymentNotConfirmedExceptionare all deleted. This flow was non-functional onmasterbefore 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), andConfigurable(isConfigured(), split out of the old singlePaymentDriverinterface). 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 +Currencytype) — never a bareintpaired separately with aCurrency. Each driver converts at its own boundary (e.g.StripeManager::toStripeAmount()/fromStripeAmount());Paymentitself only ever speaks Lunar'sPrice. - Breaking:
Modules\Core\Checkout\Services\CheckoutService::placeOrder()andconfirmPayment()are both replaced by a singleinitiatePayment(string $fingerprint, array $data = []): Modules\Core\Payment\DTOs\PaymentResult. It creates the draftOrder(Cart::createOrder(), idempotent against an existing draft) and hands off directly to the resolved driver'spay()/authorize(), per that type's newconfig('lunar.payments.types.{type}.capture_mode')key.Checkout\Events\OrderPlacedno longer dispatches fromCheckoutService— it now fires fromModules\Core\Order\Listeners\ApplyResolvedPaymentStatusonce aPaymentCaptured/PaymentAuthorizedevent actually transitions the order'splaced_at, since a draft order can now exist well before payment resolves (an async gateway). Modules\Core\Payment\Services\PaymentDriverResolver::resolve()now returns?objectinstead of the deletedPaymentDriverinterface — a driver implements several independent capability interfaces at once, so a caller does its owninstanceof SupportsPay/instanceof SupportsAuthorizationcheck, the same pattern the capability interfaces themselves are designed around.config/payment.php'scash-on-deliveryentry gainscapture_mode('pay', sinceOfflinePaymentDriveronly implementsSupportsPay) andcaptured_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(aPrice),failureReason,retriable(real on Stripe/Mastercard's own soft-decline classification, alwaysfalseon Nexi — it has no such signal),raw(the untouched gateway response, for audit),meta, andcontinuation(see below).Modules\Core\Payment\DTOs\PaymentContinuation/Modules\Core\Payment\Enums\PaymentContinuationType— what a caller does next with aPendingPaymentResult, gateway-agnostically (RedirectorClientSecret), so a storefront controller never needs gateway-specific knowledge of e.g. Stripe's ownPaymentIntentfields 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.PaymentCapturedis deliberately the same event whether money was taken viapay()(one gateway call) orauthorize()→capture()(two calls) — "a payment has been captured" is the same business fact either way. Every event carries{type, result: PaymentResult, context}—contextis an opaque bag the caller hands in and gets back untouched, soPaymentnever needs to know what aCartorOrderis. Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus(rewired, not new — previously listened to the now-deletedOrderPaymentStatusResolved) is the only place anOrder'sstatuscolumn is written in reaction to a payment outcome: it listens toPaymentCaptured/PaymentAuthorizeddirectly, reads$event->context['order_id'], and resolves the new status fromconfig('lunar.payments.types.{type}.captured_status')/authorized_status.Modules\Core\Payment\Drivers\StripePaymentDriverrewritten onto the new contracts — implements all six capability interfaces plusConfigurable. SolveshandleCallback()'s async-correlation problem (a webhook is a separate HTTP request from thepay()/authorize()call that started it) the same waylunarphp/stripe's ownStripePaymentType/ProcessStripeWebhookdo: realcart_id/order_idcolumns onLunar\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 byPaymentServiceProvider) — a boboko-owned webhook endpoint, deliberately notlunarphp/stripe's own route (which dispatches into Lunar's ownPayments::driver('stripe')flow, the flow this driver replaces). ReusesLunar\Stripe\Http\Middleware\StripeWebhookMiddlewareandStripe\Webhook::constructEvent()directly — both are genuine Stripe SDK signature verification, safe to reuse without touching the rest of that vendor package's flow. Requiresconfig('services.stripe.webhooks.lunar')set in a consuming app; nostripeconfig type entry is added toconfig/payment.phpin 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, whyPaymentResultnormalizes only what every gateway can always provide, the async-correlation pattern, and what's explicitly out of scope (aTransaction-writing listener, thestripeconfig 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 whosemetacolumn was still a genuine SQLNULL(any freshly-created cart) —Cart::$meta'sAsArrayObjectcast returnsnull, not an empty array-like object, for anullcolumn. Fixed with a null-safe fallback.Modules\Core\Shipping\Carriers\Acs\AcsRateDriver/BoxNowRateDriverreferencedLunar\Shipping\DTOs\ShippingOptionRequest, a namespace that doesn't exist in the installedlunarphp/table-rate-shippingversion (the real class isLunar\Shipping\DataTransferObjects\ShippingOptionRequest) — crashedIlluminate\Support\Manager's interface-compatibility check the moment anything touchedShippingManager::getSupportedDrivers(), including simply adding a line to a cart (viaModules\Core\Shipping\Listeners\FlushLivePricingCache).
[0.13.1] - 2026-09-03
Added
Modules\Core\Order\Listeners\RecordPaymentTransaction— writes thelunar_transactionsrow for a successfulPaymentCaptured/PaymentAuthorized/PaymentVoided/PaymentRefundedevent, via a newModules\Core\Order\Services\TransactionRecorder(moved here fromPayment\Services, and rewritten to take aPaymentResultdirectly instead of the deletedCaptureResult/RefundResultDTOs —Paymentnever writes toOrder's models,Transaction.order_idbeing required is exactly why this lives inOrder, same reasoning asApplyResolvedPaymentStatus). Closes a real gap introduced in0.13.0:Order::paymentStatus()(which derives its answer entirely from$order->transactions) always resolved toPaymentStatus::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 atype: capturetransaction andOrder::paymentStatus()correctly resolves tocaptured.
[0.12.1] - 2026-09-03
Fixed
Modules\Core\MigrateImport\Shopify\ShopifyExportImporternow attaches a variant'sVariant ImageCSV column to thatProductVariant's ownimages()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 setslabel(same value asname) when creating aLunar\Models\ProductOption, not justname. AProductOptionwith a nulllabelcrashes Lunar's ownProductOptionIndexer::toSearchableArray()(foreach()onnull) the moment that option gets reindexed — every option created by the importer before this fix has a nulllabeland needs a wipe-and-reimport (seedocs/shopify-reimport.md, new in this release) to pick up the fix, sincefirstOrCreate()never revisits an already-existing row.product_reviews.product_id's foreign key had noON DELETEclause, so deleting a reviewedProductthrew a constraint violation instead of the review going with it, unlike every other product-dependent table. New migration addscascadeOnDelete().
Added
Modules\Core\Catalog\Services\ProductIndexer::mapVariant()now embedsgtin,mpn,ean,backorder,unit_quantity,shippable,tax_ref, anddimensions(length/width/height/weight/volume, each withvalue+unit) on every indexed variant — previously onlyid/sku/stock/purchasable/options/prices/mediawere 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, filterableskusfield (every variant's SKU, deduplicated) — filtering/matching by SKU no longer requires reaching into the nestedvariantsarray.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 returnsModules\Core\Catalog\DTOs\ProductListingResult(->products: the sameIlluminate\Pagination\LengthAwarePaginatoras before,->priceBounds: a newModules\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 fromsearch(string $query, ?string $locale = null)tosearch(string $query, ?ProductFilters $filters = null, ?ProductSort $sort = null)— the$localeparameter is gone (see "every configured language, always" below);$filters/$sortapply the sameModules\Core\Catalog\Support\ProductFilterBuilder/ProductSort::toMeilisearchSort()semanticsProductService::list()already used, so a text search can now be narrowed by price/brand/stock and sorted the same way a category listing can. ProductSearchServicenow 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. Seedocs/product-search.md.- Extracted
Modules\Core\Catalog\Services\ProductService's privatebuildFilter()into a new standaloneModules\Core\Catalog\Support\ProductFilterBuilder, soProductSearchServicecan 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 aPriceSliderBoundsDTO. Used internally bylist()now; also callable directly for a caller (e.g. a text-search results page) that needs slider bounds without a fulllist()call.Modules\Core\Catalog\Services\ProductService::priceRange()gained an optionalstring $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 rawProduct::inRandomOrder()(which has no notion of that filtering). Meilisearch has noORDER 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'sid 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 targetsvariants.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) — tightensminWordSizeForTypos(1 typo only at 8+ characters, 2 typos only at 12+) and disables Meilisearch'sprefixSearchon the product index. Meilisearch's defaults for both were loose enough to produce bad matches on short Greek words (confirmed the specific case wasprefixSearch's defaultindexingTimebehavior on a shared word-start, not typo tolerance, viashowMatchesPosition). Consuming apps should run this afterlunar:meilisearch:setupwhenever the product index needs (re)provisioning — requires Meilisearch v1.12+ (prefixSearchdidn'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 baseIlluminate\Support\Collection(collect()) instead ofIlluminate\Database\Eloquent\Collection, even though every element is aProductmodel.ProductIndexer::toSearchableArray()calling->load(['media', 'variants.prices'])on that result threwBadMethodCallException: Method Illuminate\Support\Collection::load does not exist— silently failing everyMakeSearchablequeue job for a saved product (visible only asFAILin the queue log, with the real exception instorage/logs/laravel.log). Fixed by havingRecommendationServiceaccumulate into a realEloquent\Collectionfrom 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 withModules\Core\Catalog\Recommendations\SameCategoryRule(other products sharing the source product's first collection) andRandomRule(the universal fallback, placed last in the default chain). A new rule is just a class implementingModules\Core\Catalog\Contracts\RecommendationRule. Documented indocs/product-recommendations.md.Modules\Core\Catalog\Services\ProductIndexerembeds the result directly into each product's own Meilisearch document asrecommendations: [{id, name, price, image}, ...](recommendations.idfilterable) — a product detail page renders its "related products" section with zero extra queries, same reasoning as the existingcollectionsfield. Deliberately embeds anidfor the view to build a locale-correct URL from, not a resolvedhref—product.showis 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 fromProduct::saved()/Product::deleted()inCatalogServiceProvider(the latter fires for both a soft delete and a force delete, matching Scout's ownunsearchable()trigger point) — feedModules\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 forin_stock/price— seedocs/product-recommendations.md.CatalogServiceProviderscheduleslunar:search:index "Lunar\Models\Product" --refreshdaily 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.--refreshalso 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 from3dealer's actualstorefront.*translation usage:shop.price_min,shop.price_max,shop.reset(the price-filter sidebar's min/max labels and its reset link). Picked up byInstallLunarCommand's existing per-key upsert — re-runninglunar:installon 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, andlunarphp/searchto1.5.0, andfilament/filamenttov4.12.6— the first Filament v4 admin panel on this codebase.lunarphp/filament3-2faandkalnoy/nestedsetare gone, replaced by Filament v4's native two-factor auth andlunarphp/nestedset. Ran Filament's automatedfilament-v4migration tool acrosssrc/, then hand-fixed three bugs it introduced or left behind: a stale$infolistvariable reference inCartResource'sViewCartpage (the parameter had been renamed to$schemabut the body wasn't updated),ShippingMethodResourceExtensionrewritten to callgetDefaultChildComponents()(returnsarray|Schema) instead of the type-safegetChildComponents()(alwaysarray<Component>), and — unrelated to the tool, but surfaced by the same PHP version bump —InvalidCouponException'sreadonly $codeproperty illegally shadowing the built-inException::$code, renamed to$couponCode.LunarStaff::addActivitylogExcept()updated for the renamedtwo_factor_secret/two_factor_recovery_codesstaff columns (nowapp_authentication_secret/app_authentication_recovery_codes;two_factor_confirmed_atremoved). Consuming apps must runcomposer update boboko/core --with-all-dependenciesandphp artisan migrate.
Added
Modules\Core\Checkout\Contracts\PaymentDriver— the abstraction every payment provider implements:confirm(Cart $cart, string $type, string $fingerprint, array $data): OrderandisConfigured(): bool. A driver only ever callsCheckoutService::placeOrder()once it has, by whatever mechanism is native to that gateway, independently confirmed payment — never Lunar's rawCart::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 viaCheckoutService::placeOrder(), then sets the order status fromconfig("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, oflunarphp/stripe'sStripePaymentType::authorize(): that method isfinaland callsCart::createOrder()directly with no seam to redirect into our fingerprint-checkedplaceOrder(), so this class reimplements its logic (intent retrieval, capture-on-policy, status mapping viaUpdateOrderFromIntent) with that one substitution. Throws the newModules\Core\Payment\Exceptions\PaymentNotConfirmedExceptionon 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 inconfig('lunar.payments.types')that is both administratively enabled (Modules\Core\Payment\Models\PaymentMethod::enabled) and whose driver reportsisConfigured()(e.g. Stripe with no API key set is never offered, regardless of the enabled toggle).selectPaymentMethod(string $type)andconfirmPayment(string $type, array $data)both validate against this list, throwing the newUnknownPaymentTypeExceptionfor a type that isn't currently offered — re-checked inconfirmPayment()too, since a type could be disabled between selection and confirmation.CheckoutService::selectPaymentMethod()snapshotsCart::fingerprint()intocart->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 oncepayment_methodis set.confirmPayment()reads this stored fingerprint internally rather than taking one as a parameter: a storefront should never need to knowCart::fingerprint()exists or capture it at exactly the right moment itself.Modules\Core\Payment\Models\PaymentMethod— one DB row per payment type key (matchingconfig('lunar.payments.types')),enabledboolean plus adatajsonb column (starting withfee, the flat cash-on-delivery surcharge) — mirrors Lunar's ownDiscountmodel (a single jsonb column of keyed settings, not a fixed column per setting or a separate conditions table). Seeded idempotently byInstallLunarCommand(skip-if-exists per type, safe to re-run after installing a new payment-provider package), alwaysenabled: false— a newly-seeded type shouldn't go live for shoppers before staff have configured and reviewed it. Admin-editable via the newPaymentMethodResource(inline enabled toggle, modal fee editor) under Settings.ApplyCashOnDeliveryFeenow reads its surcharge fromPaymentMethodinstead of static config, so it's admin-editable without a deploy.
Fixed
CashOnDeliveryPaymentDriverrenamed toOfflinePaymentDriverand 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'sCartSession/Cartprimitives:addLine(),updateLine(),removeLine(),clear(),applyCoupon()/removeCoupon()(throwsInvalidCouponExceptionon an invalid code), and save-for-later (saveForLater()/moveToCart()/activeLines()/savedLines(), backed by ameta.saved_for_laterflag and a newModules\Core\Cart\Pipelines\ZeroSavedForLaterPricecart-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 indocs/cart.md.Modules\Core\Checkout\Services\CheckoutService— the boboko-owned API for the checkout stage (address → shipping selection → order placement), sitting betweenCartServiceandOrder:setShippingAddress()/setBillingAddress(),getShippingOptions()/selectShippingOption()(throws the newInvalidShippingOptionExceptionon an identifier that doesn't resolve — previously a silent no-op), andplaceOrder(string $fingerprint)(the fingerprint is mandatory, not optional — forces re-confirmation via Lunar's ownFingerprintMismatchExceptionif the cart changed since the shopper last saw its total). DispatchesShippingAddressSet/BillingAddressSet/ShippingOptionSelected/OrderPlaced, each carrying richer, already-resolved payload (e.g. the resolvedShippingOption, not just its identifier) thanCartService's events. No exception wrapping otherwise — Lunar's ownCartException/FingerprintMismatchExceptionare already the right shape for a storefront to render as form errors. Documented indocs/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 ownCart::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 atcustomers/{id}via the plaincustomer_idcolumn, no extra query via thecustomerrelation).Modules\Core\Cart\Commands\DetectAbandonedCarts(boboko:cart:detect-abandoned, scheduled hourly) dispatchesModules\Core\Recovery\Events\CartAbandoned/CheckoutAbandonedfor carts/checkouts past the abandonment cutoff — detection only, no persistence; a real tracking table is left for whenRecoveryis built as its own concern. Fixed a self-defeating bug from an earlier draft: marking a cart as notified by writing to it bumpedupdated_at, which immediately un-staled it for the next run's own cutoff check.- Merged the
Shipping-Carriersbranch: live carrier rate quoting and fulfillment for ACS Courier and Box Now (Modules\Core\Shipping\Carriers\{Acs,BoxNow}) on top oflunarphp/table-rate-shipping—AcsRateDriver/BoxNowRateDriver(live + static price-break resolution),AcsFulfillmentService/BoxNowFulfillmentService(shipment creation, label printing, cancellation via the newModules\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 anOrderViewExtensionadding 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\CachesLivePricingcaches a live-priced carrier quote per(rate, cart)for 30 minutes — a real, billed API call that's otherwise re-run on everygetShippingOptions()/selectShippingOption()call within the same checkout attempt.Modules\Core\Shipping\Listeners\FlushLivePricingCacheinvalidates 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 anySupportsLivePricingdriver, 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 vendorManageShippingRatespage'safterStateHydratedcallback for the price field had no null-guard for a rate with zerobasePrices, 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'sManagePickupManifestspage instead of the Dashboard — Filament falls back to the first item of the first registered navigation group when no explicithomeUrl()is set, andManagePickupManifestshad nonavigationGroup/navigationSortof its own. Fixed via explicitnavigationGroup = '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\CartResourcegives staff read-only visibility into carts in the Filament admin panel — Lunar ships no cart admin view at all. Scoped to carts with a knownuser_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), notCart::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 ownCart::scopeActive().getNavigationBadge()shows the abandoned-cart count in the sidebar via a singleCOUNT(*)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 indocs/cart.md.
[0.7.0] - 2026-08-27
Added
Modules\Core\Catalog\Services\CollectionServiceprovides category browsing/nav AND single-collection lookup from Meilisearch, mirroringProductServiceexactly (list(),getById(),getBySlug(), same locale-resolution logic).Modules\Core\Catalog\Services\CollectionIndexerextends Lunar's ownLunar\Search\CollectionIndexer(which only carriedid/name/created_at) to addparent_id,_lft/_rgt(nested-set tree position, filterable/sortable),collection_group_id,slugs, andthumbnail.Modules\Core\Catalog\DTOs\CollectionFilterssupportsparentId(children of a specific collection),groupId, androotOnly(top-level collections,parent_id IS NULL— mutually exclusive withparentId).Modules\Core\Catalog\Enums\CollectionSortaddsPosition(_lft:asc, the recommended default for nav/tree UIs — matches admin arrangement order),Name,Newest. Must be registered in a consuming app'sconfig/lunar/search.php(Lunar\Models\Collection::class => CollectionIndexer::class), same asProductIndexer. Documented indocs/collections.md.Modules\Core\Localization\Services\StorefrontLabels::all()extracts the default storefront UI label list out ofInstallLunarCommandinto 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 viaTranslationService::create(). This makes it safe to add new keys toStorefrontLabels::all()later and re-runlunar:installon 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 indocs/localization.md("Seeding").Modules\Core\Catalog\Services\CollectionIndexeraddsancestors—[{id, name}, ...]ordered root-first (via the newly eager-loadedancestorsrelation) — so a breadcrumb can render directly fromCollectionService::getById()/getBySlug()with zero extra queries, andproduct_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 samecollection_idsfieldProductFilters(collectionId:)filters against. Documented indocs/collections.md, including the reindex-ordering gotcha (product_countneeds the product index reindexed first).Modules\Core\Catalog\Services\ProductIndexeradds a filterablein_stockboolean —trueif any variant currently passesProductVariant::canBeFulfilledAtQuantity(1)(Lunar's own purchasability rule, not a naivestock > 0check).Modules\Core\Catalog\DTOs\ProductFiltersgets a matchinginStockOnlyflag. 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; seedocs/product-listing.md("Stock goes stale between orders").Modules\Core\Catalog\Services\ProductService::facets(string $field, ?ProductFilters $filters = null): arrayreturns 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 wayfilter/sortalready are — no adoption of Lunar's separateSearchManager/Searchfacade needed.ProductService::priceRange(?ProductFilters $filters = null): array{min, max}covers the numeric-field casefacets()explicitly doesn't (pricewould otherwise return one "facet" per exact price) — backed by Meilisearch'sfacetStats, notfacetDistribution.priceRange()always excludesminPrice/maxPricefrom the filter it builds (via a new$excludeparameter on the privatebuildFilter()), 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 indocs/product-listing.md.
Changed
- Breaking: Renamed the
Productmodule toCatalog, flattened. Every class underModules\Core\Product\*(Contracts,DTOs,Enums,Services,Observers,Filament\Extensions,OptionTypes) now lives underModules\Core\Catalog\*at the same sub-path — e.g.Modules\Core\Product\Services\ProductServiceis nowModules\Core\Catalog\Services\ProductService,Modules\Core\Product\DTOs\ProductFiltersis nowModules\Core\Catalog\DTOs\ProductFilters. Class names themselves are unchanged (stillProductService,ProductIndexer,ProductFilters, etc.) — only the namespace/folder moved, to make room forCollectionas a sibling concern under the sameCatalogumbrella rather than a disconnected top-level module. Consuming apps must update everyuse Modules\Core\Product\...import and any FQCN reference (config/lunar/search.php's indexer registration, service provider bindings). - Breaking:
Modules\Core\Providers\ProductServiceProviderrenamed toModules\Core\Providers\CatalogServiceProvider(composer.json's provider list updated accordingly) — it now only wiresCatalog-namespace classes (ProductOptionTypeManager,ProductOptionReindexObserver), so the name follows the same by-concern convention asLocalizationServiceProvider/ReviewServiceProvider. - Breaking:
Modules\Core\Review's flatExtensions//Pages/folders now nest underFilament/, matching the strict per-concern subfolder convention already applied toProduct(nowCatalog)/Localization.Modules\Core\Review\Extensions\ProductResourceExtensionis nowModules\Core\Review\Filament\Extensions\ProductResourceExtension;Modules\Core\Review\Pages\ManageProductReviewsis nowModules\Core\Review\Filament\Pages\ManageProductReviews.Modules\Core\Review\Models\ProductReviewis 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\ProductIndexeradds a new filterablecollection_idsfield — every directly-assigned collection's id unioned with all of its ancestors' ids (via the newly eager-loadedcollections.ancestors) — andProductService::buildFilter()now filterscollectionIdagainstcollection_idsinstead of the oldcollections.id. The display-onlycollectionsfield ({id, name}, direct assignments) is unchanged and no longer filterable.
[0.6.1] - 2026-08-27
Added
Modules\Core\Product\Contracts\ProductOptionTypeInterfacedescribes how a category ofLunar\Models\ProductOption(e.g. "Color", "Size") behaves — what structured data its values carry in their free-formmetajsonb column, and how an admin edits it via Filament — without introducing a new model. Registered viaModules\Core\Product\Services\ProductOptionTypeManager::get()->register([...])(a singleton registry, same shape asModules\Core\Notification\NotificationRegistry) from a service provider'sboot(). An admin then picks one perProductOptionfrom an "Option Type" dropdown on the option's own edit form (added byModules\Core\Product\Filament\Extensions\ProductOptionResourceExtension), stored inProductOption::meta['option_type']— deliberately not tied to the option'shandle, since a shop's own handle naming shouldn't have to match a type's key.Modules\Core\Product\Filament\Extensions\ValuesRelationManagerExtensionhooks Lunar's ownValuesRelationManager(both extensions viaLunarPanel::extensions(), registered inCorePlugin) 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 newModules\Core\Providers\ProductServiceProvider. Documented indocs/product-options.md.Modules\Core\Product\Services\ProductIndexer::mapVariant()now includes each option'shandle(alongside its translated name) in a variant's indexedoptions[]— previously only the translatedoption/valuenames andmetawere indexed, with no stable, locale-independent identifier for which option a value belongs to.Modules\Core\Product\Observers\ProductOptionReindexObserver, wired in the newModules\Core\Providers\ProductServiceProvider, keeps Meilisearch in sync when aProductOptionorProductOptionValueis saved or deleted — e.g. picking an Option Type or editing a color's hex.ProductIndexer::mapVariant()embeds each option value'smetadirectly 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 everyLunar\Models\Productwhose variants use the changed option (or option value) via theproduct_option_value_product_variantpivot, and calls->searchable()on each.
Changed
- Breaking:
Modules\Core\Product\Services\ProductIndexer's indexedcollectionsfield is now an array of{id, name}objects instead of two parallel arrays (collectionsas bare ID strings,collection_namesas translated names joined only by array index).collection_namesis removed. Filtering by collection now targets the nested fieldcollections.id(Meilisearch supports filtering on nested object fields), not barecollections—Modules\Core\Product\Services\ProductService::buildFilter()updated accordingly;ProductFilters(collectionId: ...)'s public API is unchanged. Runphp artisan lunar:meilisearch:setupthenlunar:search:index --refreshafter upgrading (see docs/product-listing.md "Gotchas"). - Breaking:
ProductIndexer's indexedreview_count/average_ratingtop-level keys are folded into the existingreviewskey:reviewsis now{items, count, average_rating}instead of a bare array withreview_count/average_ratingas separate sibling keys.reviews(the array of review items) moved toreviews.items.
[0.6.0] - 2026-08-27
Added
Modules\Core\Localization\Models\LanguageLineextendsspatie/laravel-translation-loader'sLanguageLineto fall back to the store's actual default language (LanguageCache::defaultLocale(), backed by Lunar'slanguages.defaultflag) instead of the package's stock behavior of falling back to the staticconfig('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 viaconfig('translation-loader.model')inLocalizationServiceProvider::register(); no consuming app changes needed. Documented indocs/localization.md("Fallback locale follows the store's default language").
Changed
- Breaking:
Modules\Core\Catalog\ProductService::list()now returns a realIlluminate\Pagination\LengthAwarePaginator(built from the localized Meilisearch hits) instead of a plainarray{data, meta}— gives callers normal Laravel pagination behaviour ($products->links(), standard JSON serialization) without ever touching Scout's rawpaginateRaw()response directly.getById()/getBySlug()are unaffected (still return?array). ProductService::withLocalizedFields()(used bylist(),getById(),getBySlug()) no longer hardcodesname/descriptionas the only translated fields — it now reads everyTranslatedTextattribute onProductfromLunar\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 justname_*/description_*.- Extracted
Modules\Core\Localization\Services\LanguageCache(cached read layer over Lunar'slanguagestable:all(),defaultLocale(),availableLocales(),forget()) out ofLocaleMiddleware, which previously owned this as private/static methods despite not being middleware-specific behavior.LocaleMiddlewarenow takesLanguageCachevia constructor injection.LocaleMiddleware::defaultLocale()/forgetLanguagesCache()(static) are removed — useapp(LanguageCache::class)or injectLanguageCachedirectly.
Fixed
Modules\Core\MigrateImport\JudgeMe\Resolvers\ProductResolver::resolve()picked whicheverlunar_urlsrow 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 oneProductrow 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\JudgeMeExportImporterlogging "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 againstlunar_products— viaProduct::query(), so Eloquent'sSoftDeletesglobal scope excludes trashed rows — so only a URL pointing at a live product resolves.Modules\Core\Review\Models\ProductReviewhad noregisterMediaConversions()at all, unlikeProduct/ProductVariantwhich get one automatically from Lunar's ownLunar\Base\StandardMediaDefinitions.Modules\Core\Search\ProductIndexer::mapMedia()is shared across product, variant, and review media and always requests thesmallconversion — the first time a review had an attached image, indexing it threwSpatie\MediaLibrary\MediaCollections\Exceptions\InvalidConversion, silently failing the product'sMakeSearchablequeue job (and everything queued after it, since Scout batches). Added a matchingsmallconversion (300×300, same fit/border/background as Lunar's standard one) directly onProductReview.
Breaking
-
Merged
Modules\Core\CatalogandModules\Core\Searchinto a singleModules\Core\Productconcern, since both existed purely to serveProduct(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\ProductServiceModules\Core\Catalog\ProductFilters→Modules\Core\Product\DTOs\ProductFiltersModules\Core\Catalog\ProductSort→Modules\Core\Product\Enums\ProductSortModules\Core\Search\ProductIndexer→Modules\Core\Product\Services\ProductIndexerModules\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 atProductIndexerby FQCN.Modules\Core\Catalog\ProductOptionTypeInterface(in-progress, not yet wired to anything) was deliberately left in place rather than moved. -
Reorganized
Modules\Core\Localizationunder 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\LocaleMiddlewareModules\Core\Localization\LanguageCacheObserver→Modules\Core\Localization\Observers\LanguageCacheObserverModules\Core\Localization\TranslationReader→Modules\Core\Localization\Services\TranslationReaderModules\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 inLocalizationServiceProvider) 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 asortparameter (newProductSortenum:PriceAsc,PriceDesc,Newest), translated into a Meilisearchsortclause —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 markspricesortable (Lunar's base indexer only markscreated_at/updated_at/skus/status). Requires re-syncing index settings (php artisan lunar:meilisearch:setup) on existing stores. Documented indocs/product-listing.md("Sorting").
[0.5.3] - 2026-08-26
Fixed
Modules\Core\Search\ProductIndexer::toSearchableArray()threwcolumn reference "id" is ambiguouson Postgres when computingchannel_ids—$model->channels()->wherePivot('enabled', true)->pluck('id')joinslunar_channelsandlunar_channelables, both of which have anidcolumn, and the unqualifiedpluck('id')left Postgres unable to resolve which table's column to select (SQLite/MySQL tolerated the ambiguity). Qualified aspluck('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 viafirstWhere('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 withaltLocales, a collection of every other configured language (code,name,urlfor the current route each), so a language switcher orhreflangtags scale to any number of locales. Documented indocs/localization.md("Shared view data — language switcher andhreflangtags").
[0.5.1] - 2026-08-25
Added
Modules\Core\Search\ProductIndexernow indexeschannel_ids(filterable) — Lunar's base indexer only marksstatusas 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 olderProductsbranch whose remote had been deleted; the branch's other, now-supersededProductIndexerchanges were dropped in favor of the richer indexer already onmaster(collections, price, variants, reviews — see0.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.ProductFiltersDTO: optionalcollectionId,brand,minPrice,maxPrice, translated into a Meilisearchfilterexpression.- Listing results are locale-aware:
withLocalizedFields()resolvesname/descriptionfrom the indexer's per-locale fields, falling back to the store's default language (viaLocaleMiddleware::defaultLocale()) when the current locale has no translation yet, instead of rendering blank. Modules\Core\Search\ProductIndexerexpanded 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 +metafor swatches, per-currency prices, variant media), and reviews (reviews,review_count,average_rating— public-safe fields only,reviewer_emaildeliberately 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 theProductrow 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 fromProductService, which is for browsing/filtering without a query term.docs/product-listing.mdanddocs/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::nameisn'tattribute_data(sotranslateAttribute()silently returnsnullfor it), a runningqueue:workprocess not picking up an edited Scout indexer class, and Scout'spaginateRaw()->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. Aback()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): alocaleroute-middleware alias, opt-in per shop (not pushed onto thewebgroup globally, since admin/Livewire/webhook routes must not be locale-redirected). Reads the first URL segment against Lunar's ownlanguagestable, setsApp::setLocale(), and redirects unprefixed/unknown-locale requests to a resolved locale (Accept-Languagematch → 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 viaModules\Core\Localization\LanguageCacheObserverdispatchingLanguageCreated/LanguageUpdated/LanguageDeletedevents (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 onLanguageUpdated) migrates every affectedLanguageLine.textkey 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 existingLanguageLinetranslations permanently unreachable.
- Language list cached with
- Storefront UI label translations: pulled in
spatie/laravel-translation-loader(self-registers via Composer package auto-discovery; its loader extends Laravel's file-basedFileLoaderand merges DB translations on top — existing Filament/Lunar vendorlang/strings are unaffected). Labels are looked up via Laravel's native__('storefront.nav.cart'), kept in its ownstorefrontgroup so nothing collides with Lunar/Filament's own translation groups.Modules\Core\Command\InstallLunarCommand(overridinglunar: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 byLanguageLine's own forever-cache) — for sharing to a view as$labelsor@json()-ing to JS, on top of__()for single-key Blade lookups.- Admin UI:
Modules\Core\Localization\Filament\Resources\LanguageLineResource(registered inCorePlugin) lists/searches/filterslanguage_linesand edits each row'sgroup,key, and one text input per locale currently inlunar_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 forLanguageLine— the Filament resource's Create/Edit/Delete pages route through it rather than Filament's default direct-model writes. DispatchesTranslationCreated/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 inLanguageLine'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 fromtext, or agroup/keyrename, can't leave a stale cached array behind.LogTranslationActivity— audits every write via the existingModules\Core\Logging\ActivityLogService(lunaractivity log channel), samecreated/updated/deletedshape as every other domain write in this project. Properties are flattened withArr::dot()before logging (text.en,text.elinstead of a nestedtextobject) since Filament's Activity resource renderspropertieswith a flatKeyValuefield that can't display nested arrays.
Modules\Core\Providers\LocalizationServiceProvider— split out of the growingCoreServiceProvider(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/typesenseengines, selectable via Scout's ownSCOUT_DRIVERconfig) andlunarphp/meilisearch, wiring Meilisearch in as the search engine for products.Search\ProductIndexeroverrides 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-coreconcern: the actual Meilisearch container, host port, and master key live in each consuming app's owndocker-compose.yml/.env(e.g.3dealer), the same way Postgres and Valkey do —boboko-coreonly declares the PHP package dependency and the indexing code.
[0.2.0] - 2026-07-10
Added
- Product reviews (
Modules\Core\Review): a newProductReviewmodel +product_reviewstable (plain, unprefixed — same convention asimport_mappings), linked to Lunar'sProductvia aProduct::reviews()macro (registered inCorePlugin, sinceLunar\Models\Productis a vendor model and can't be edited directly).- JudgeMe CSV review importer (
MigrateImport\JudgeMe\JudgeMeExportImporter), wired into the existingboboko:migrate:import --source=judgeme --type=exportcommand: reads a Judge.me review export, resolves each row'sproduct_handleto a Lunar product viaLunar\Models\Url, and creates/updatesProductReviewrows idempotently viaimport_mappings(source=judgeme,source_type=review, keyed on Judge.me'smetaobject_handle). Rows with no matching product are skipped with a logged warning rather than failing the whole import. - Review images (
picture_urlsin 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 viaReview\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, settingreplied_at. The View modal shows full review detail (body, reviewer email, location, source, dates, reply, downloaded images).
- JudgeMe CSV review importer (
Fixed
Shopify\ShopifyExportImporternever wrote a LunarUrl(slug) row for imported products, despitedocs/shopify-import.mdspecifying it should — meaning no code outside the importer itself could resolve "which Lunar product has handle X" (only the importer's own privateimport_mappingsbookkeeping could). It now creates/updates a defaultUrlrow (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-shippingplugin (ShippingPlugin) directly onCorePlugin, 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:importArtisan 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 tostorage/app/private/imports/, so answering e.g.shopifypicks up the first CSV found inimports/shopify/automatically.ImportSpec,Importerinterface, andImporterFactory(source+type → importer class) as the extension points for future sources (WooCommerce, etc.) and mechanisms (API vs. file export).import_mappingstable +ImportMappingmodel: a polymorphic (source, source_type, external_id) → model mapping used by every resolver to make imports idempotent and safely re-runnable.DefaultLocalehelper wrapping Lunar'sLanguage::getDefault()->code, used anywhere a translatable field needs a locale key, instead of assumingapp()->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+ProductGroupgroup 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 viaProduct::addMedia(), matches local export images by UUID first, filename fallback),PriceResolver(minor-unit conversion per currency),ImportAttributeResolverandProductAttributeResolver(customcost_per_item/seo_title/seo_descriptionattributes, field-type-awareattribute_datawriting).
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, requiredProductOption.handle, per-groupAttribute.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
ProductOptionResolvercreated duplicateProductOption/ProductOptionValuerows 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:importcould 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
Userinstead ofCustomer(UserOtpService,UserOtpMail), replacing the earlier customer-scoped OTP flow. UserCreatedevent with aCreateCustomerForUserlistener to provision a Lunar customer automatically when a user is created.UserRelationManagerfor 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 (seeStoic::class). config/core.phpfor module-level configuration.AuthServiceProviderandCustomerServiceProvidernow register alongsideCoreServiceProvider.- Migrations: add OTP to
users, drop OTP from Lunarcustomers, droppasswordfromusers, makenamenullable onusersand Lunarcustomers. docs/modules.mddocumenting module structure.
Removed
CustomerOtpMailandCustomerOtpService, superseded by the user-based OTP flow.
Dependencies
- Added explicit
symfony/yamlrequirement (used directly byStoic::loadConfig()).