Compare commits

...
5 Commits
10 changed files with 280 additions and 131 deletions
+14
View File
@@ -4,6 +4,15 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [0.14.0] - 2026-09-03
### Changed
- **Breaking:** `Modules\Core\Catalog\Services\ProductSearchService::search()` now returns `Modules\Core\Catalog\DTOs\ProductListingResult` — the exact same shape `ProductService::list()` already returns — instead of a bare `Illuminate\Database\Eloquent\Collection<Product>` of hydrated models with no pagination at all. New signature: `search(string $query, ?ProductFilters $filters = null, ?ProductSort $sort = null, int $perPage = 24, int $page = 1): ProductListingResult`. `->products` is a real `LengthAwarePaginator` of plain, localized indexed-document arrays (not Eloquent models, not Scout's raw response) — a search results page and a category listing page are now interchangeable from a controller's perspective: same DTO, same `ProductCard::fromIndexed()` mapping, same pagination/sort/tag/price-slider handling. `->priceBounds`/`->availableTags` are scoped to the search query itself (delegated to `ProductService::priceSliderBounds()`/`availableTags()`, both of which already accepted a `$query` param for this).
- `Modules\Core\Catalog\Services\ProductService::availableTags()` is now `public` (was `private`) and takes an optional `$query` parameter, so `ProductSearchService::search()` can reuse it directly instead of reimplementing the same facet call.
### Added
- `Modules\Core\Catalog\Support\ProductDocumentLocalizer` — the per-locale field resolution and raw-Meilisearch-response unwrapping (`withLocalizedFields()`, `hitsFrom()`) extracted out of `ProductService` into its own class, since `ProductSearchService` needed the exact same logic against the exact same kind of document. Both services now depend on this one class instead of `ProductService` owning logic a second service also needed.
## [0.13.0] - 2026-09-03
### Changed
@@ -27,6 +36,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- `Modules\Core\Checkout\Services\CheckoutService::selectPaymentMethod()` crashed (`Call to a member function toArray() on null`) the first time it ran against a cart whose `meta` column was still a genuine SQL `NULL` (any freshly-created cart) — `Cart::$meta`'s `AsArrayObject` cast returns `null`, not an empty array-like object, for a `null` column. Fixed with a null-safe fallback.
- `Modules\Core\Shipping\Carriers\Acs\AcsRateDriver`/`BoxNowRateDriver` referenced `Lunar\Shipping\DTOs\ShippingOptionRequest`, a namespace that doesn't exist in the installed `lunarphp/table-rate-shipping` version (the real class is `Lunar\Shipping\DataTransferObjects\ShippingOptionRequest`) — crashed `Illuminate\Support\Manager`'s interface-compatibility check the moment anything touched `ShippingManager::getSupportedDrivers()`, including simply adding a line to a cart (via `Modules\Core\Shipping\Listeners\FlushLivePricingCache`).
## [0.13.1] - 2026-09-03
### Added
- `Modules\Core\Order\Listeners\RecordPaymentTransaction` — writes the `lunar_transactions` row for a successful `PaymentCaptured`/`PaymentAuthorized`/`PaymentVoided`/`PaymentRefunded` event, via a new `Modules\Core\Order\Services\TransactionRecorder` (moved here from `Payment\Services`, and rewritten to take a `PaymentResult` directly instead of the deleted `CaptureResult`/`RefundResult` DTOs — `Payment` never writes to `Order`'s models, `Transaction.order_id` being required is exactly why this lives in `Order`, same reasoning as `ApplyResolvedPaymentStatus`). Closes a real gap introduced in `0.13.0`: `Order::paymentStatus()` (which derives its answer entirely from `$order->transactions`) always resolved to `PaymentStatus::Offline` — its "no transactions at all" fallback — regardless of what actually happened, since nothing had ever written a row. Verified live: a captured offline payment now produces a `type: capture` transaction and `Order::paymentStatus()` correctly resolves to `captured`.
## [0.12.1] - 2026-09-03
### Fixed
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "boboko/core",
"description": "Core module — authentication and shared panel behaviour",
"type": "library",
"version": "0.13.0",
"version": "0.14.0",
"autoload": {
"psr-4": {
"Modules\\Core\\": "src/"
+43 -7
View File
@@ -2,12 +2,14 @@
namespace Modules\Core\Catalog\Services;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Pagination\LengthAwarePaginator;
use Lunar\Facades\AttributeManifest;
use Lunar\Models\Language;
use Lunar\Models\Product;
use Modules\Core\Catalog\DTOs\ProductFilters;
use Modules\Core\Catalog\DTOs\ProductListingResult;
use Modules\Core\Catalog\Enums\ProductSort;
use Modules\Core\Catalog\Support\ProductDocumentLocalizer;
use Modules\Core\Catalog\Support\ProductFilterBuilder;
/**
@@ -21,20 +23,37 @@ class ProductSearchService
{
public function __construct(
private readonly ProductFilterBuilder $filterBuilder,
private readonly ProductDocumentLocalizer $localizer,
private readonly ProductService $products,
) {}
/**
* Returns the exact same Modules\Core\Catalog\DTOs\ProductListingResult
* ProductService::list() does — a search results page and a category
* listing page consume identically shaped data, one call each. The
* paginator itself carries plain, localized indexed-document arrays
* (not hydrated Product models), same as list().
*
* priceBounds/availableTags are delegated to ProductService's own
* priceSliderBounds()/availableTags() rather than reimplemented here —
* both already accept a $query param for exactly this reason (a search
* page's slider/tag sidebar should reflect only the products search
* actually matched, not the whole catalog).
*
* $filters/$sort apply the exact same semantics ProductService::list()
* uses for collection browsing (same ProductFilterBuilder, same
* ProductSort::toMeilisearchSort()) — a shopper narrowing a text search
* by price/brand/stock gets identical filter behavior to narrowing a
* category listing, since both go through the same Meilisearch `filter`
* clause underneath.
*
* @return Collection<int, Product>
*/
public function search(string $query, ?ProductFilters $filters = null, ?ProductSort $sort = null): Collection
{
public function search(
string $query,
?ProductFilters $filters = null,
?ProductSort $sort = null,
int $perPage = 24,
int $page = 1,
): ProductListingResult {
$options = [
'attributesToSearchOn' => $this->searchableFields(),
'filter' => $this->filterBuilder->build($filters),
@@ -44,9 +63,26 @@ class ProductSearchService
$options['sort'] = [$sort->toMeilisearchSort()];
}
return Product::search($query)
$paginator = Product::search($query)
->options($options)
->get();
->paginateRaw(perPage: $perPage, page: $page);
$data = collect($this->localizer->hitsFrom($paginator))
->map(fn (array $product) => $this->localizer->withLocalizedFields($product))
->all();
$products = new LengthAwarePaginator(
items: $data,
total: $paginator->total(),
perPage: $paginator->perPage(),
currentPage: $paginator->currentPage(),
options: ['path' => LengthAwarePaginator::resolveCurrentPath()],
);
$priceBounds = $this->products->priceSliderBounds($filters, $filters?->minPrice, $filters?->maxPrice, $query);
$availableTags = $this->products->availableTags($filters, $query);
return new ProductListingResult($products, $priceBounds, $availableTags);
}
/**
+16 -74
View File
@@ -2,17 +2,13 @@
namespace Modules\Core\Catalog\Services;
use Illuminate\Contracts\Pagination\LengthAwarePaginator as LengthAwarePaginatorContract;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\App;
use Lunar\Base\AttributeManifest;
use Lunar\FieldTypes\TranslatedText;
use Lunar\Models\Product;
use Modules\Core\Localization\Services\LanguageCache;
use Modules\Core\Catalog\DTOs\PriceSliderBounds;
use Modules\Core\Catalog\DTOs\ProductFilters;
use Modules\Core\Catalog\DTOs\ProductListingResult;
use Modules\Core\Catalog\Enums\ProductSort;
use Modules\Core\Catalog\Support\ProductDocumentLocalizer;
use Modules\Core\Catalog\Support\ProductFilterBuilder;
/**
@@ -27,8 +23,7 @@ use Modules\Core\Catalog\Support\ProductFilterBuilder;
class ProductService
{
public function __construct(
private readonly LanguageCache $languages,
private readonly AttributeManifest $attributes,
private readonly ProductDocumentLocalizer $localizer,
private readonly ProductFilterBuilder $filterBuilder,
) {}
@@ -65,8 +60,8 @@ class ProductService
->options($options)
->paginateRaw(perPage: $perPage, page: $page);
$data = collect($this->hitsFrom($paginator))
->map(fn (array $product) => $this->withLocalizedFields($product))
$data = collect($this->localizer->hitsFrom($paginator))
->map(fn (array $product) => $this->localizer->withLocalizedFields($product))
->all();
$products = new LengthAwarePaginator(
@@ -91,12 +86,20 @@ class ProductService
* alphabetically; Meilisearch's facetDistribution has no defined order
* of its own.
*
* $query defaults to '' (every product, same as list()'s own default
* text query) — same reasoning as priceRange()'s own $query: pass the
* shopper's search text here too so a search page's own tag sidebar
* reflects only the products search actually matched. Public (not
* private, unlike the rest of this listing-only orchestration) so
* ProductSearchService::search() can reuse it directly rather than
* reimplementing the same facet call a second time.
*
* @return array<int, string>
*/
private function availableTags(?ProductFilters $filters): array
public function availableTags(?ProductFilters $filters, string $query = ''): array
{
$filter = $this->filterBuilder->build($filters, exclude: ['tag']);
$tags = $this->rawFacets('tags', $filter)['facetDistribution']['tags'] ?? [];
$tags = $this->rawFacets('tags', $filter, $query)['facetDistribution']['tags'] ?? [];
return collect($tags)->keys()->sort()->values()->all();
}
@@ -287,69 +290,8 @@ class ProductService
->options(['filter' => $filter])
->paginateRaw(perPage: $limit, page: 1);
return collect($this->hitsFrom($paginator))
->map(fn (array $product) => $this->withLocalizedFields($product))
return collect($this->localizer->hitsFrom($paginator))
->map(fn (array $product) => $this->localizer->withLocalizedFields($product))
->all();
}
/**
* Resolves every translated Product attribute's current-locale value from the
* indexer's per-locale `{handle}_{locale}` fields (e.g. `name_el`, `name_en`,
* `seo_title_el`, ...) into a plain `{handle}` key, falling back to the store's
* default language (LanguageCache::defaultLocale()) when the current locale
* has no translation - e.g. a product with no English copy yet still shows its
* Greek name on /en/ rather than rendering blank.
*
* Which handles are translated is read from AttributeManifest - the same
* source Lunar's own ScoutIndexer reads when exploding a TranslatedText
* attribute into `{handle}_{locale}` keys at index time - rather than a fixed
* list, so a store's own custom translated attributes (e.g. `seo_title`) are
* picked up automatically with no change here. The raw per-locale keys are
* then stripped, since once resolved, callers only ever need the one that
* matched the current locale.
*
* Deliberately not config('app.locale') - App::setLocale() overwrites that
* config value on every request, so by request time it's just whatever the
* current locale already is, not a stable fallback.
*/
private function withLocalizedFields(array $product): array
{
$locale = App::getLocale();
$fallbackLocale = $this->languages->defaultLocale();
$availableLocales = $this->languages->availableLocales();
foreach ($this->translatedAttributeHandles() as $handle) {
$product[$handle] = $product[$handle.'_'.$locale] ?? $product[$handle.'_'.$fallbackLocale] ?? null;
foreach ($availableLocales as $availableLocale) {
unset($product[$handle.'_'.$availableLocale]);
}
}
return $product;
}
/**
* @return array<int, string>
*/
private function translatedAttributeHandles(): array
{
return $this->attributes->getSearchableAttributes((new Product)->getMorphClass())
->filter(fn ($attribute) => $attribute->type === TranslatedText::class)
->pluck('handle')
->all();
}
/**
* For the Meilisearch driver, Scout's paginateRaw() puts the whole raw response
* (hits, query, processingTimeMs, ...) in items(), not a plain list of hits - the
* actual documents are under the 'hits' key.
*/
private function hitsFrom(LengthAwarePaginatorContract $paginator): array
{
$rawResponse = $paginator->items();
return collect($rawResponse['hits'] ?? [])->values()->all();
}
}
@@ -0,0 +1,86 @@
<?php
namespace Modules\Core\Catalog\Support;
use Illuminate\Contracts\Pagination\LengthAwarePaginator as LengthAwarePaginatorContract;
use Illuminate\Support\Facades\App;
use Lunar\Base\AttributeManifest;
use Lunar\FieldTypes\TranslatedText;
use Lunar\Models\Product;
use Modules\Core\Localization\Services\LanguageCache;
/**
* Shared between Modules\Core\Catalog\Services\ProductService and
* ProductSearchService — both read the same kind of Meilisearch document
* (Modules\Core\Catalog\Services\ProductIndexer's shape) and need the
* exact same per-locale field resolution and raw-response unwrapping.
* Extracted rather than duplicated so a future fix to the localization-
* fallback logic only needs to be made once.
*/
class ProductDocumentLocalizer
{
public function __construct(
private readonly LanguageCache $languages,
private readonly AttributeManifest $attributes,
) {}
/**
* Resolves every translated Product attribute's current-locale value from the
* indexer's per-locale `{handle}_{locale}` fields (e.g. `name_el`, `name_en`,
* `seo_title_el`, ...) into a plain `{handle}` key, falling back to the store's
* default language (LanguageCache::defaultLocale()) when the current locale
* has no translation - e.g. a product with no English copy yet still shows its
* Greek name on /en/ rather than rendering blank.
*
* Which handles are translated is read from AttributeManifest - the same
* source Lunar's own ScoutIndexer reads when exploding a TranslatedText
* attribute into `{handle}_{locale}` keys at index time - rather than a fixed
* list, so a store's own custom translated attributes (e.g. `seo_title`) are
* picked up automatically with no change here. The raw per-locale keys are
* then stripped, since once resolved, callers only ever need the one that
* matched the current locale.
*
* Deliberately not config('app.locale') - App::setLocale() overwrites that
* config value on every request, so by request time it's just whatever the
* current locale already is, not a stable fallback.
*/
public function withLocalizedFields(array $product): array
{
$locale = App::getLocale();
$fallbackLocale = $this->languages->defaultLocale();
$availableLocales = $this->languages->availableLocales();
foreach ($this->translatedAttributeHandles() as $handle) {
$product[$handle] = $product[$handle.'_'.$locale] ?? $product[$handle.'_'.$fallbackLocale] ?? null;
foreach ($availableLocales as $availableLocale) {
unset($product[$handle.'_'.$availableLocale]);
}
}
return $product;
}
/**
* For the Meilisearch driver, Scout's paginateRaw() puts the whole raw response
* (hits, query, processingTimeMs, ...) in items(), not a plain list of hits - the
* actual documents are under the 'hits' key.
*/
public function hitsFrom(LengthAwarePaginatorContract $paginator): array
{
$rawResponse = $paginator->items();
return collect($rawResponse['hits'] ?? [])->values()->all();
}
/**
* @return array<int, string>
*/
private function translatedAttributeHandles(): array
{
return $this->attributes->getSearchableAttributes((new Product)->getMorphClass())
->filter(fn ($attribute) => $attribute->type === TranslatedText::class)
->pluck('handle')
->all();
}
}
@@ -38,6 +38,7 @@ class StorefrontLabels
'auth.login' => ['en' => 'Log In', 'el' => 'Σύνδεση'],
'auth.logout' => ['en' => 'Log Out', 'el' => 'Αποσύνδεση'],
'search.placeholder' => ['en' => 'Search products…', 'el' => 'Αναζήτηση προϊόντων…'],
'search.results_for' => ['en' => 'Search results for ', 'el' => 'Αποτελέσματα αναζήτησης για '],
'customer_reviews' => [
'en' => '{0} No customer reviews|{1} :count customer review|[2,*] :count customer reviews',
'el' => '{0} Καμία αξιολόγηση πελάτη|{1} :count αξιολόγηση πελάτη|[2,*] :count αξιολογήσεις πελατών',
@@ -0,0 +1,57 @@
<?php
namespace Modules\Core\Order\Listeners;
use Lunar\Models\Order;
use Modules\Core\Order\Services\TransactionRecorder;
use Modules\Core\Payment\Events\PaymentAuthorized;
use Modules\Core\Payment\Events\PaymentCaptured;
use Modules\Core\Payment\Events\PaymentRefunded;
use Modules\Core\Payment\Events\PaymentVoided;
/**
* Writes the Transaction row for a successful payment outcome — the
* "record what happened" half of reacting to Payment's events, separate
* from Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus's "update
* the order's status" half. Both listen to the same events for the same
* reason: two independent reactions to one payment outcome, neither
* calling the other (see docs/payments.md).
*
* Only registered against the SUCCESS events (PaymentCaptured,
* PaymentAuthorized, PaymentVoided, PaymentRefunded) — a Failed event
* never reaches here, since a failed attempt moved no money and settled
* nothing worth auditing as a Transaction row (see OrderServiceProvider's
* registration and docs/payments.md's "Explicitly out of scope" section
* on why no Failed-side Order reaction exists at all).
*
* Same defensive $context['order_id'] ?? null early-return as
* ApplyResolvedPaymentStatus — $context is caller-supplied and optional,
* and this listener must not crash for a future non-Checkout caller of
* pay()/authorize() with no order_id in its context.
*/
class RecordPaymentTransaction
{
public function __construct(
private readonly TransactionRecorder $transactions,
) {}
public function handle(PaymentCaptured|PaymentAuthorized|PaymentVoided|PaymentRefunded $event): void
{
$orderId = $event->context['order_id'] ?? null;
if ($orderId === null) {
return;
}
$order = Order::findOrFail($orderId);
$type = match ($event::class) {
PaymentAuthorized::class => 'intent',
PaymentCaptured::class => 'capture',
PaymentRefunded::class => 'refund',
PaymentVoided::class => 'void',
};
$this->transactions->record($order, $type, $event->type, $event->result);
}
}
@@ -0,0 +1,55 @@
<?php
namespace Modules\Core\Order\Services;
use Lunar\Models\Order;
use Lunar\Models\Transaction;
use Modules\Core\Payment\DTOs\PaymentResult;
use Modules\Core\Payment\Enums\PaymentResultStatus;
/**
* Writes the Transaction row a Payment operation's PaymentResult becomes —
* the one place that translates Payment's gateway-agnostic result into
* Lunar's own transactions table, in the same shape lunarphp/stripe's own
* StoreCharges already writes (type, success, amount, reference, driver).
* Lives in Order, not Payment — Transaction.order_id is required, and
* Payment never writes to another module's models (see docs/payments.md);
* this is the "read the event, do the write" half of that boundary, same
* shape as Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus.
*
* Kept as its own class (not inlined into the listener that calls it) so a
* future admin action (a manually-triggered capture/refund from Filament)
* can write a row the same way, without going through an event at all.
*/
class TransactionRecorder
{
/**
* $type is Lunar's own transaction type string — 'intent' (an
* authorize()-produced hold), 'capture' (settled funds, whether via
* pay() directly or capture() settling a prior intent), 'refund',
* 'void' is NOT one of Lunar's three built-in types (Order::
* paymentStatus() only ever reads 'intent'/'capture'/'refund' — see
* Modules\Core\Order\Support\OrderStatus::payment()) — a void never
* moved money, so it's still recorded for audit but $success reflects
* whether the RELEASE succeeded, not a captured amount.
*
* $driver is the payment type key (e.g. 'stripe', 'cash-on-delivery'),
* not a class name — matches the $type PaymentCaptured/etc. events
* themselves carry, and what Transaction.driver already means
* elsewhere in this codebase (see the old, now-removed
* TransactionRecorder this replaces).
*/
public function record(Order $order, string $type, string $driver, PaymentResult $result): Transaction
{
return $order->transactions()->create([
'success' => $result->status === PaymentResultStatus::Succeeded,
'type' => $type,
'driver' => $driver,
'amount' => $result->amount->value,
'reference' => $result->reference,
'status' => $result->status->name,
'notes' => $result->failureReason,
'meta' => $result->meta,
]);
}
}
@@ -1,49 +0,0 @@
<?php
namespace Modules\Core\Payment\Services;
use Lunar\Models\Order;
use Lunar\Models\Transaction;
use Modules\Core\Payment\DTOs\CaptureResult;
use Modules\Core\Payment\DTOs\RefundResult;
/**
* Writes the Transaction row a SupportsRefunds/SupportsCaptures driver's
* result becomes — the one place that translates a gateway-agnostic
* RefundResult/CaptureResult into Lunar's own transactions table, in the
* same shape lunarphp/stripe's StoreCharges already writes (type, success,
* amount, reference, driver, notes). Kept here rather than inside each
* driver so every driver's rows land in a consistent shape that
* Order::paymentStatus() and TransactionObserver both already understand,
* without any driver needing to know about either.
*/
class TransactionRecorder
{
public function recordRefund(Order $order, string $driver, RefundResult $result, ?string $notes = null): Transaction
{
return $order->transactions()->create([
'success' => $result->success,
'type' => 'refund',
'driver' => $driver,
'amount' => $result->amount,
'reference' => $result->reference,
'status' => $result->success ? 'succeeded' : 'failed',
'notes' => $notes ?? $result->message,
'meta' => $result->meta,
]);
}
public function recordCapture(Order $order, string $driver, CaptureResult $result, ?string $notes = null): Transaction
{
return $order->transactions()->create([
'success' => $result->success,
'type' => 'capture',
'driver' => $driver,
'amount' => $result->amount,
'reference' => $result->reference,
'status' => $result->success ? 'succeeded' : 'failed',
'notes' => $notes ?? $result->message,
'meta' => $result->meta,
]);
}
}
+7
View File
@@ -9,6 +9,7 @@ use Lunar\Models\Transaction;
use Modules\Core\Notification\NotificationRegistry;
use Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus;
use Modules\Core\Order\Listeners\DeriveOrderDeliveredFromShipment;
use Modules\Core\Order\Listeners\RecordPaymentTransaction;
use Modules\Core\Order\Notifications\OrderCapturedNotification;
use Modules\Core\Order\Notifications\OrderDeliveredNotification;
use Modules\Core\Order\Notifications\OrderRefundedNotification;
@@ -18,6 +19,8 @@ use Modules\Core\Order\Observers\TransactionObserver;
use Modules\Core\Order\Support\OrderStatus;
use Modules\Core\Payment\Events\PaymentAuthorized;
use Modules\Core\Payment\Events\PaymentCaptured;
use Modules\Core\Payment\Events\PaymentRefunded;
use Modules\Core\Payment\Events\PaymentVoided;
use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier;
class OrderServiceProvider extends ServiceProvider
@@ -33,6 +36,10 @@ class OrderServiceProvider extends ServiceProvider
Event::listen(ShipmentStatusUpdatedByCarrier::class, DeriveOrderDeliveredFromShipment::class);
Event::listen(PaymentCaptured::class, ApplyResolvedPaymentStatus::class);
Event::listen(PaymentAuthorized::class, ApplyResolvedPaymentStatus::class);
Event::listen(PaymentCaptured::class, RecordPaymentTransaction::class);
Event::listen(PaymentAuthorized::class, RecordPaymentTransaction::class);
Event::listen(PaymentVoided::class, RecordPaymentTransaction::class);
Event::listen(PaymentRefunded::class, RecordPaymentTransaction::class);
NotificationRegistry::get()->register([
OrderDeliveredNotification::class,