Files
core/CHANGELOG.md
T

1255 lines
140 KiB
Markdown
Raw Normal View History

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