Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
448da869a9 | ||
|
|
1287b513cd | ||
|
|
b2919f1f4b |
@@ -4,6 +4,22 @@ 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.12.0] - 2026-09-03
|
||||
|
||||
### Changed
|
||||
- **Breaking:** `Modules\Core\Catalog\Services\ProductService::list()` now returns `Modules\Core\Catalog\DTOs\ProductListingResult` (`->products`: the same `Illuminate\Pagination\LengthAwarePaginator` as before, `->priceBounds`: a new `Modules\Core\Catalog\DTOs\PriceSliderBounds`) instead of returning the paginator directly. A caller doing `$service->list(...)->items()`/`->through(...)` must update to `$service->list(...)->products->items()`/`->through(...)`. This collapses what used to be two separate calls a controller had to orchestrate itself (`list()` for products, `priceRange()` + manual floor/ceil/"is this actually filtered" math for the slider) into one.
|
||||
- **Breaking:** `Modules\Core\Catalog\Services\ProductSearchService::search()`'s signature changed from `search(string $query, ?string $locale = null)` to `search(string $query, ?ProductFilters $filters = null, ?ProductSort $sort = null)` — the `$locale` parameter is gone (see "every configured language, always" below); `$filters`/`$sort` apply the same `Modules\Core\Catalog\Support\ProductFilterBuilder`/`ProductSort::toMeilisearchSort()` semantics `ProductService::list()` already used, so a text search can now be narrowed by price/brand/stock and sorted the same way a category listing can.
|
||||
- `ProductSearchService` now targets every configured store language's fields on every search (`Lunar\Models\Language::all()`), not just the current request locale plus the store's default language. The old `{current, default}` pairing silently stopped catching anything outside those two locales whenever they were equal (a single-language store, or a shopper browsing in the default language) — always searching every configured language closes that gap in both directions. See `docs/product-search.md`.
|
||||
- Extracted `Modules\Core\Catalog\Services\ProductService`'s private `buildFilter()` into a new standalone `Modules\Core\Catalog\Support\ProductFilterBuilder`, so `ProductSearchService` can apply the exact same Meilisearch filter-clause semantics to a text query, instead of reimplementing filter-building a second time.
|
||||
|
||||
### Added
|
||||
- `Modules\Core\Catalog\Services\ProductService::priceSliderBounds()` — `priceRange()` rounded to whole currency units (floor/ceil) plus whether the given selected min/max actually narrows it, returned as a `PriceSliderBounds` DTO. Used internally by `list()` now; also callable directly for a caller (e.g. a text-search results page) that needs slider bounds without a full `list()` call.
|
||||
- `Modules\Core\Catalog\Services\ProductService::priceRange()` gained an optional `string $query = ''` parameter, so a caller can scope the price range to a text search's own matches (pass the shopper's search text) instead of always spanning the whole catalog.
|
||||
- `Modules\Core\Catalog\Services\ProductService::random(int $limit)` — random products still scoped to the Meilisearch index's own channel/status visibility, unlike a raw `Product::inRandomOrder()` (which has no notion of that filtering). Meilisearch has no `ORDER BY RANDOM()` equivalent, so this fetches every matching id only (`attributesToRetrieve: ['id']`), shuffles in PHP, then fetches the full localized documents for just the ids picked, restoring the shuffled order afterward (Meilisearch's `id IN [...]` filter doesn't preserve list order on its own).
|
||||
- `Modules\Core\Catalog\Services\ProductService::variantSummaries(array $product)` — the id/price/image of every variant on a product document, for a variant picker/swatch list, without a caller reaching into `$product['variants'][n]['prices'][0]`/`['media'][0]` itself.
|
||||
- `Modules\Core\Catalog\Services\ProductSearchService::search()` now also targets `variants.options.value` — a variant's own option value (e.g. "Κάπτεν Γαμέρικα" on a "Name" option) is matchable by search even when that text never appears in the product's own name or description.
|
||||
- `php artisan lunar:meilisearch:tune-product-search` (`Modules\Core\Command\TuneProductSearchCommand`) — tightens `minWordSizeForTypos` (1 typo only at 8+ characters, 2 typos only at 12+) and disables Meilisearch's `prefixSearch` on the product index. Meilisearch's defaults for both were loose enough to produce bad matches on short Greek words (confirmed the specific case was `prefixSearch`'s default `indexingTime` behavior on a shared word-start, not typo tolerance, via `showMatchesPosition`). Consuming apps should run this after `lunar:meilisearch:setup` whenever the product index needs (re)provisioning — **requires Meilisearch v1.12+** (`prefixSearch` didn't exist as a configurable setting before then).
|
||||
|
||||
## [0.11.1] - 2026-09-01
|
||||
|
||||
### Fixed
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "boboko/core",
|
||||
"description": "Core module — authentication and shared panel behaviour",
|
||||
"type": "library",
|
||||
"version": "0.11.1",
|
||||
"version": "0.12.0",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Modules\\Core\\": "src/"
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ return [
|
||||
| Lunar's own config.
|
||||
|
|
||||
| 'payment_driver' is boboko-owned, alongside Lunar's own 'driver' key —
|
||||
| it's the Modules\Core\Payment\Contracts\PaymentDriver class
|
||||
| it's the Modules\Core\Checkout\Contracts\PaymentDriver class
|
||||
| CheckoutService::confirmPayment() resolves via the container and calls
|
||||
| confirm() on. Kept on the same row as 'driver' rather than a second,
|
||||
| separately-keyed map, so a type's full definition — Lunar's driver,
|
||||
|
||||
+44
-15
@@ -1,8 +1,9 @@
|
||||
# Product Listing
|
||||
|
||||
`Modules\Core\Catalog\Services\ProductService` provides catalog browsing/filtering AND single-product
|
||||
lookup for a storefront — `list()`, `getById()`, `getBySlug()` — all reading directly from the
|
||||
Meilisearch index rather than the database. One data source for everything this service does.
|
||||
lookup for a storefront — `list()`, `getById()`, `getBySlug()`, `random()`, `variantSummaries()` —
|
||||
all reading directly from the Meilisearch index rather than the database. One data source for
|
||||
everything this service does.
|
||||
|
||||
This is separate from `Modules\Core\Catalog\Services\ProductSearchService` (see `product-search.md`), which
|
||||
handles free-text query search. `ProductService` is for browsing/lookup without a search term.
|
||||
@@ -28,13 +29,14 @@ use Modules\Core\Catalog\Enums\ProductSort;
|
||||
|
||||
$service = app(ProductService::class);
|
||||
|
||||
// List everything, paginated — returns a real Illuminate\Pagination\LengthAwarePaginator,
|
||||
// built from the localized Meilisearch hits (not Scout's own paginateRaw() result — see
|
||||
// "Meilisearch driver quirk" below), so it behaves like any other Laravel paginator.
|
||||
$products = $service->list(perPage: 24, page: 1);
|
||||
// One call for everything a listing page needs — products AND the price slider's
|
||||
// bounds together, as a Modules\Core\Catalog\DTOs\ProductListingResult. A caller
|
||||
// used to have to call list() and priceSliderBounds() (or the older priceRange())
|
||||
// separately and glue the results together itself; that's now list()'s own job.
|
||||
$listing = $service->list(perPage: 24, page: 1);
|
||||
|
||||
// Filter by collection, brand, price range, and/or stock
|
||||
$products = $service->list(
|
||||
$listing = $service->list(
|
||||
filters: new ProductFilters(collectionId: 17, minPrice: 10.0, maxPrice: 50.0, inStockOnly: true),
|
||||
perPage: 24,
|
||||
page: 1,
|
||||
@@ -42,8 +44,13 @@ $products = $service->list(
|
||||
|
||||
// Sort — cheapest/priciest first, or newest first. Omit for Meilisearch's default
|
||||
// relevance ordering (irrelevant here since the query is always empty).
|
||||
$products = $service->list(perPage: 24, page: 1, sort: ProductSort::PriceAsc);
|
||||
$listing = $service->list(perPage: 24, page: 1, sort: ProductSort::PriceAsc);
|
||||
|
||||
$products = $listing->products; // a real Illuminate\Pagination\LengthAwarePaginator,
|
||||
// built from the localized Meilisearch hits (not Scout's
|
||||
// own paginateRaw() result — see "Meilisearch driver
|
||||
// quirk" below), so it behaves like any other Laravel
|
||||
// paginator.
|
||||
$products->items(); // array of Meilisearch documents (plain arrays, not models)
|
||||
$products->total();
|
||||
$products->perPage();
|
||||
@@ -51,12 +58,32 @@ $products->currentPage();
|
||||
$products->lastPage();
|
||||
$products->links(); // in a Blade view — renders pagination links as usual
|
||||
|
||||
$bounds = $listing->priceBounds; // Modules\Core\Catalog\DTOs\PriceSliderBounds
|
||||
$bounds->floor; // ?int — floor() of the matching range's minimum, in whole currency units
|
||||
$bounds->ceil; // ?int — ceil() of the matching range's maximum
|
||||
$bounds->filtered; // bool — whether the applied filters' minPrice/maxPrice actually
|
||||
// narrow the slider below/above these bounds (drives whether a
|
||||
// "clear filter" control should show)
|
||||
|
||||
// Single product, by primary key
|
||||
$product = $service->getById(367); // array, or null if not found
|
||||
|
||||
// Single product, by URL slug (any locale — slugs are indexed across all languages)
|
||||
$product = $service->getBySlug('erotika-mprelok'); // array, or null if not found
|
||||
|
||||
// $limit random products — still scoped to the index's own default channel/status
|
||||
// visibility, unlike Eloquent's Product::inRandomOrder() (which has no notion of
|
||||
// that filtering at all). Meilisearch has no ORDER BY RANDOM() equivalent, so this
|
||||
// pulls every matching id only, shuffles in PHP, then fetches the full localized
|
||||
// documents for just the ids picked — see random()'s own docblock.
|
||||
$randomProducts = $service->random(13); // array of documents, same shape as list()'s items
|
||||
|
||||
// The id/price/image of every variant on a product document — the base price and
|
||||
// thumbnail a variant picker/swatch list needs, without reaching into
|
||||
// $product['variants'][n]['prices'][0]/['media'][0] yourself.
|
||||
$variants = $service->variantSummaries($product);
|
||||
// [['id' => 1204, 'price' => 19.99, 'image' => 'https://.../thumb.jpg'], ...]
|
||||
|
||||
// Facet counts for a sidebar — value => matching product count, scoped to whatever
|
||||
// $filters is passed. Does NOT exclude the faceted field itself from $filters — see
|
||||
// facets()'s docblock for why, and how to build a standard "every option's count,
|
||||
@@ -64,11 +91,13 @@ $product = $service->getBySlug('erotika-mprelok'); // array, or null if not fo
|
||||
$brandCounts = $service->facets('brand', filters: new ProductFilters(collectionId: 17));
|
||||
// ['3Dealer.gr - 3D printed creations' => 48, 'Kraniou Topos - 3D printed creations' => 135]
|
||||
|
||||
// Min/max price across matching products, for sizing a price-range slider.
|
||||
// minPrice/maxPrice are ALWAYS excluded from the filter driving this (unlike
|
||||
// facets(), which doesn't auto-exclude) — the slider's own bounds shouldn't shrink
|
||||
// to whatever range is currently selected on it. Other filters (collectionId,
|
||||
// brand, inStockOnly) still apply normally.
|
||||
// Min/max price across matching products — the raw, unrounded values list() itself
|
||||
// uses to build priceBounds above. minPrice/maxPrice are ALWAYS excluded from the
|
||||
// filter driving this (unlike facets(), which doesn't auto-exclude) — the slider's
|
||||
// own bounds shouldn't shrink to whatever range is currently selected on it. Other
|
||||
// filters (collectionId, brand, inStockOnly) still apply normally. Pass $query too
|
||||
// to scope the range to a text search's own matches (see product-search.md) rather
|
||||
// than the whole catalog.
|
||||
$range = $service->priceRange(new ProductFilters(collectionId: 17));
|
||||
// ['min' => 0.0, 'max' => 120.0]
|
||||
```
|
||||
@@ -77,8 +106,8 @@ All `ProductFilters` fields are optional; only the ones set are added to the Mei
|
||||
|
||||
`facets()` only makes sense on discrete-value filterable fields (`brand`, `in_stock`) — a numeric
|
||||
field like `price` would return one "facet" per exact price, not a usable range bucket. Use
|
||||
`priceRange()` for `price` instead, which reads Meilisearch's `facetStats` (min/max), a different
|
||||
feature from `facetDistribution`.
|
||||
`priceRange()` (or `list()`'s own `priceBounds`) for `price` instead, which reads Meilisearch's
|
||||
`facetStats` (min/max), a different feature from `facetDistribution`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+39
-13
@@ -24,36 +24,62 @@ merges `$builder->options` directly into the search request).
|
||||
## Usage
|
||||
|
||||
```php
|
||||
use Modules\Core\Catalog\DTOs\ProductFilters;
|
||||
use Modules\Core\Catalog\Enums\ProductSort;
|
||||
use Modules\Core\Catalog\Services\ProductSearchService;
|
||||
|
||||
$results = app(ProductSearchService::class)->search('running shoes');
|
||||
// or an explicit locale, bypassing App::getLocale():
|
||||
$results = app(ProductSearchService::class)->search('running shoes', 'el');
|
||||
|
||||
// Filters/sort apply the exact same semantics ProductService::list() uses for
|
||||
// collection browsing (same ProductFilterBuilder, same ProductSort) — a shopper
|
||||
// narrowing a text search by price/brand/stock gets identical filter behavior
|
||||
// to narrowing a category listing.
|
||||
$results = app(ProductSearchService::class)->search(
|
||||
'running shoes',
|
||||
filters: new ProductFilters(brand: 'Acme', minPrice: 20.0, inStockOnly: true),
|
||||
sort: ProductSort::PriceAsc,
|
||||
);
|
||||
```
|
||||
|
||||
Returns an `Illuminate\Database\Eloquent\Collection` of `Lunar\Models\Product` — Scout's
|
||||
`->get()` hydrates real models from the database after the Meilisearch query, so relations
|
||||
(`variants`, `brand`, `media`, etc.) are available on the results as normal.
|
||||
|
||||
`$locale` defaults to `App::getLocale()` — already set correctly on every storefront request by
|
||||
`Modules\Core\Localization\Middleware\LocaleMiddleware` (see `localization.md`), so callers in controllers
|
||||
don't need to pass it explicitly.
|
||||
There is no `$locale` parameter — see "Field list is dynamic, not hardcoded" below for why
|
||||
every configured store language is always searched, regardless of the current request locale.
|
||||
|
||||
---
|
||||
|
||||
## Missing-translation fallback
|
||||
## Missing-translation fallback, in both directions
|
||||
|
||||
If a product was only ever given an English name, `name_el` doesn't exist on that document at
|
||||
all (Lunar's indexer only writes a `{handle}_{locale}` field for locales actually present in the
|
||||
attribute's stored data — see `ScoutIndexer::mapSearchableAttributes()`). Searching strictly
|
||||
against `name_el` would make that product invisible to Greek-locale search, even though it's a
|
||||
real catalog item.
|
||||
against the current request's locale field would make that product invisible whenever a shopper's
|
||||
locale doesn't match the language it happens to be translated into.
|
||||
|
||||
To avoid silently hiding incompletely-translated products, `ProductSearchService` targets **both**
|
||||
the resolved locale's fields **and** the default language's fields
|
||||
(`Lunar\Models\Language::getDefault()->code`) — e.g. searching in `el` targets `name_el`,
|
||||
`name_en`, `description_el`, `description_en` together (assuming `en` is the default language).
|
||||
A product missing an `el` translation still matches via its `en` fields.
|
||||
`ProductSearchService` avoids this by targeting **every configured store language's fields**
|
||||
(`Lunar\Models\Language::all()`) on every search, not just the current request locale plus the
|
||||
store default — e.g. with `el`/`en` configured, every search targets `name_el`, `name_en`,
|
||||
`description_el`, `description_en` together, regardless of which locale the shopper is browsing
|
||||
in. This is deliberately not scoped to "current locale + default locale": if the current locale
|
||||
already equals the default (a single-language store, or a shopper browsing in the default
|
||||
language), that pairing collapses to one locale and stops catching anything else — always
|
||||
searching every configured language avoids that gap in both directions, at the cost of a larger
|
||||
`attributesToSearchOn` list as the store's language count grows.
|
||||
|
||||
---
|
||||
|
||||
## Variant option values are searched too
|
||||
|
||||
Alongside the locale-suffixed attribute fields, every search also targets
|
||||
`variants.options.value` directly — e.g. a variant named "Κάπτεν Γαμέρικα" on a "Name" option
|
||||
matches a search for that text, even though it never appears in the product's own name or
|
||||
description. This isn't one of Lunar's own attributes (`AttributeManifest` has no entry for it),
|
||||
so it can't be discovered the way `name`/`description` are — it's a structural field of
|
||||
`Modules\Core\Catalog\Services\ProductIndexer`'s own document shape (see `ProductIndexer::mapVariant()`),
|
||||
added here directly. Not locale-suffixed — each option value is stored as one already-resolved
|
||||
string per variant.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Catalog\DTOs;
|
||||
|
||||
/**
|
||||
* A price range slider's bounds and whether it's currently narrowed —
|
||||
* built by ProductService::priceSliderBounds(), which owns the floor/ceil
|
||||
* rounding and "is this actually a meaningful filter" comparison, so a
|
||||
* controller (CategoryController, SearchController, ...) doesn't have to
|
||||
* reimplement that rule itself.
|
||||
*/
|
||||
class PriceSliderBounds
|
||||
{
|
||||
public function __construct(
|
||||
public readonly ?int $floor,
|
||||
public readonly ?int $ceil,
|
||||
public readonly bool $filtered,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Catalog\DTOs;
|
||||
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
|
||||
/**
|
||||
* Everything a listing page needs from one ProductService::list() call —
|
||||
* the product page itself plus the price slider's bounds, so a controller
|
||||
* makes one service call instead of orchestrating list() and
|
||||
* priceSliderBounds() separately. list() still issues two Meilisearch
|
||||
* requests internally (the product search and the price facet stats — see
|
||||
* priceSliderBounds()'s own docblock for why they can't be merged into one
|
||||
* without changing the slider's UX), but that's this DTO's job to hide,
|
||||
* not the controller's to know about.
|
||||
*/
|
||||
class ProductListingResult
|
||||
{
|
||||
public function __construct(
|
||||
public readonly LengthAwarePaginator $products,
|
||||
public readonly PriceSliderBounds $priceBounds,
|
||||
) {}
|
||||
}
|
||||
@@ -3,10 +3,12 @@
|
||||
namespace Modules\Core\Catalog\Services;
|
||||
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Lunar\Facades\AttributeManifest;
|
||||
use Lunar\Models\Language;
|
||||
use Lunar\Models\Product;
|
||||
use Modules\Core\Catalog\DTOs\ProductFilters;
|
||||
use Modules\Core\Catalog\Enums\ProductSort;
|
||||
use Modules\Core\Catalog\Support\ProductFilterBuilder;
|
||||
|
||||
/**
|
||||
* Lunar's Meilisearch indexer flattens translated attributes into locale-suffixed
|
||||
@@ -17,39 +19,69 @@ use Lunar\Models\Product;
|
||||
*/
|
||||
class ProductSearchService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProductFilterBuilder $filterBuilder,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* $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, ?string $locale = null): Collection
|
||||
public function search(string $query, ?ProductFilters $filters = null, ?ProductSort $sort = null): Collection
|
||||
{
|
||||
$locale ??= App::getLocale();
|
||||
$defaultLocale = Language::getDefault()->code;
|
||||
$options = [
|
||||
'attributesToSearchOn' => $this->searchableFields(),
|
||||
'filter' => $this->filterBuilder->build($filters),
|
||||
];
|
||||
|
||||
if ($sort !== null) {
|
||||
$options['sort'] = [$sort->toMeilisearchSort()];
|
||||
}
|
||||
|
||||
return Product::search($query)
|
||||
->options([
|
||||
'attributesToSearchOn' => $this->searchableFields($locale, $defaultLocale),
|
||||
])
|
||||
->options($options)
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Target the resolved locale's fields plus the default locale's fields, so a
|
||||
* product that's only ever been translated into the default language still
|
||||
* surfaces when searched in another locale, instead of becoming invisible
|
||||
* until every product is fully translated.
|
||||
* Targets every configured store language's fields, not just the current
|
||||
* request locale plus the store default — a shopper browsing in Greek
|
||||
* typing an English word (or vice versa) should still match a product
|
||||
* whose only translation for that text happens to be in a third
|
||||
* language. There's no per-request "current locale" concept in this
|
||||
* method any more: which fields exist to search on is a property of the
|
||||
* store's configured languages, not of who's asking.
|
||||
*
|
||||
* Also targets variants.options.value directly — a variant's option
|
||||
* value (e.g. "Κάπτεν Γαμέρικα" on a "Name" option) is how ProductIndexer
|
||||
* already indexes it (see mapVariant()), but it isn't one of Lunar's own
|
||||
* attributes, so it can't come from AttributeManifest the way name/
|
||||
* description do; it's a structural field of the document, added here
|
||||
* directly instead. Not locale-suffixed like the attribute-manifest
|
||||
* fields — option values are stored as one already-resolved string per
|
||||
* variant (see ProductIndexer::translatedName()), not per-locale.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function searchableFields(string $locale, string $defaultLocale): array
|
||||
private function searchableFields(): array
|
||||
{
|
||||
$handles = AttributeManifest::getSearchableAttributes(Product::morphName())
|
||||
->pluck('handle');
|
||||
|
||||
$locales = array_unique([$locale, $defaultLocale]);
|
||||
$locales = Language::all()->pluck('code');
|
||||
|
||||
return $handles
|
||||
$attributeFields = $handles
|
||||
->crossJoin($locales)
|
||||
->map(fn (array $pair) => "{$pair[0]}_{$pair[1]}")
|
||||
->map(fn (array $pair) => "{$pair[0]}_{$pair[1]}");
|
||||
|
||||
return $attributeFields
|
||||
->push('variants.options.value')
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
@@ -4,14 +4,16 @@ namespace Modules\Core\Catalog\Services;
|
||||
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator as LengthAwarePaginatorContract;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Collection;
|
||||
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\ProductFilterBuilder;
|
||||
|
||||
/**
|
||||
* Storefront product listing/filtering AND single-product lookup, all reading directly
|
||||
@@ -27,17 +29,33 @@ class ProductService
|
||||
public function __construct(
|
||||
private readonly LanguageCache $languages,
|
||||
private readonly AttributeManifest $attributes,
|
||||
private readonly ProductFilterBuilder $filterBuilder,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Returns a real LengthAwarePaginator (not Scout's own paginateRaw() result -
|
||||
* see "Meilisearch driver quirk" below) so a controller/view gets normal
|
||||
* pagination behaviour ($products->links(), JSON serialization, etc.)
|
||||
* without ever touching the raw Meilisearch response directly.
|
||||
* One call for everything a listing page needs: the product page AND
|
||||
* the price slider's bounds — a controller used to have to call this
|
||||
* plus priceSliderBounds() separately and glue the results together
|
||||
* itself; that orchestration now happens in here instead. Still issues
|
||||
* two Meilisearch requests under the hood (the product search, and a
|
||||
* separate price-facet-stats query — see priceSliderBounds()'s
|
||||
* docblock for why they can't be merged into one without changing the
|
||||
* slider's own UX), but the caller only ever makes one call.
|
||||
*
|
||||
* $filters->minPrice/$filters->maxPrice double as both the applied
|
||||
* product filter AND the "is the slider actually narrowed" comparison
|
||||
* in priceSliderBounds() — the same values, used two ways, so nothing
|
||||
* new needs to be threaded through separately.
|
||||
*
|
||||
* The paginator itself is a real LengthAwarePaginator (not Scout's own
|
||||
* paginateRaw() result - see "Meilisearch driver quirk" below) so a
|
||||
* controller/view gets normal pagination behaviour ($products->links(),
|
||||
* JSON serialization, etc.) without ever touching the raw Meilisearch
|
||||
* response directly.
|
||||
*/
|
||||
public function list(?ProductFilters $filters = null, int $perPage = 24, int $page = 1, ?ProductSort $sort = null): LengthAwarePaginator
|
||||
public function list(?ProductFilters $filters = null, int $perPage = 24, int $page = 1, ?ProductSort $sort = null): ProductListingResult
|
||||
{
|
||||
$options = ['filter' => $this->buildFilter($filters)];
|
||||
$options = ['filter' => $this->filterBuilder->build($filters)];
|
||||
|
||||
if ($sort !== null) {
|
||||
$options['sort'] = [$sort->toMeilisearchSort()];
|
||||
@@ -51,13 +69,17 @@ class ProductService
|
||||
->map(fn (array $product) => $this->withLocalizedFields($product))
|
||||
->all();
|
||||
|
||||
return new LengthAwarePaginator(
|
||||
$products = new LengthAwarePaginator(
|
||||
items: $data,
|
||||
total: $paginator->total(),
|
||||
perPage: $paginator->perPage(),
|
||||
currentPage: $paginator->currentPage(),
|
||||
options: ['path' => LengthAwarePaginator::resolveCurrentPath()],
|
||||
);
|
||||
|
||||
$priceBounds = $this->priceSliderBounds($filters, $filters?->minPrice, $filters?->maxPrice);
|
||||
|
||||
return new ProductListingResult($products, $priceBounds);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -79,7 +101,7 @@ class ProductService
|
||||
*/
|
||||
public function facets(string $field, ?ProductFilters $filters = null): array
|
||||
{
|
||||
return $this->rawFacets($field, $this->buildFilter($filters))['facetDistribution'][$field] ?? [];
|
||||
return $this->rawFacets($field, $this->filterBuilder->build($filters))['facetDistribution'][$field] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,12 +112,17 @@ class ProductService
|
||||
* not `facetDistribution` — the right feature for a numeric field's range,
|
||||
* where `facets('price')` would otherwise return one entry per exact price.
|
||||
*
|
||||
* $query defaults to '' (every product, same as list()'s own default text
|
||||
* query) — pass the shopper's search text here too so a search page's own
|
||||
* price slider spans only the products that search actually matched,
|
||||
* rather than the whole catalog's price range.
|
||||
*
|
||||
* @return array{min: ?float, max: ?float} null/null if no product matches
|
||||
*/
|
||||
public function priceRange(?ProductFilters $filters = null): array
|
||||
public function priceRange(?ProductFilters $filters = null, string $query = ''): array
|
||||
{
|
||||
$filter = $this->buildFilter($filters, exclude: ['price']);
|
||||
$stats = $this->rawFacets('price', $filter)['facetStats']['price'] ?? null;
|
||||
$filter = $this->filterBuilder->build($filters, exclude: ['price']);
|
||||
$stats = $this->rawFacets('price', $filter, $query)['facetStats']['price'] ?? null;
|
||||
|
||||
return [
|
||||
'min' => $stats['min'] ?? null,
|
||||
@@ -103,9 +130,36 @@ class ProductService
|
||||
];
|
||||
}
|
||||
|
||||
private function rawFacets(string $field, ?string $filter): array
|
||||
/**
|
||||
* priceRange() rounded to whole euros (floor/ceil, so the slider's ends
|
||||
* are never tighter than what's actually in range) plus whether
|
||||
* $selectedMinPrice/$selectedMaxPrice actually narrow it — the same
|
||||
* "floor/ceil + is this a real filter" rule CategoryController and
|
||||
* SearchController each used to duplicate inline. $selectedMinPrice/
|
||||
* $selectedMaxPrice are the currently-applied filter values (e.g.
|
||||
* CategoryListing::$minPrice), not part of $filters itself, since
|
||||
* $filters here must already exclude price the way priceRange() expects.
|
||||
*/
|
||||
public function priceSliderBounds(
|
||||
?ProductFilters $filters,
|
||||
?float $selectedMinPrice,
|
||||
?float $selectedMaxPrice,
|
||||
string $query = '',
|
||||
): PriceSliderBounds {
|
||||
$priceRange = $this->priceRange($filters, $query);
|
||||
|
||||
$floor = $priceRange['min'] !== null ? (int) floor($priceRange['min']) : null;
|
||||
$ceil = $priceRange['max'] !== null ? (int) ceil($priceRange['max']) : null;
|
||||
|
||||
$filtered = ($selectedMinPrice !== null && $selectedMinPrice > ($floor ?? PHP_INT_MIN))
|
||||
|| ($selectedMaxPrice !== null && $selectedMaxPrice < ($ceil ?? PHP_INT_MAX));
|
||||
|
||||
return new PriceSliderBounds($floor, $ceil, $filtered);
|
||||
}
|
||||
|
||||
private function rawFacets(string $field, ?string $filter, string $query = ''): array
|
||||
{
|
||||
return Product::search('')
|
||||
return Product::search($query)
|
||||
->options([
|
||||
'filter' => $filter,
|
||||
'facets' => [$field],
|
||||
@@ -133,15 +187,87 @@ class ProductService
|
||||
return $this->findOneWhere("id = \"{$id}\"");
|
||||
}
|
||||
|
||||
/**
|
||||
* The id/price/image of every variant on a product document (from
|
||||
* getById()/getBySlug()'s own 'variants' array) — the base price and
|
||||
* thumbnail a variant picker/swatch list needs, without a caller
|
||||
* reaching into $product['variants'][n]['prices'][0]/['media'][0]
|
||||
* itself. Domain shaping (which price/image represents a variant),
|
||||
* not presentation — a card's href/layout stays a storefront concern
|
||||
* (e.g. App\Catalog\ProductCard in 3dealer), but "the variant's price
|
||||
* is its first price row" is a rule about the data, true regardless of
|
||||
* which app renders it.
|
||||
*
|
||||
* @param array $product A document from getById()/getBySlug().
|
||||
* @return array<int, array{id: int, price: ?float, image: ?string}>
|
||||
*/
|
||||
public function variantSummaries(array $product): array
|
||||
{
|
||||
return collect($product['variants'] ?? [])
|
||||
->map(fn (array $variant) => [
|
||||
'id' => $variant['id'],
|
||||
'price' => $variant['prices'][0]['price'] ?? null,
|
||||
'image' => $variant['media'][0]['url'] ?? null,
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* $limit random products, still scoped to the index's own default
|
||||
* visibility (channel/status), unlike Eloquent's Product::inRandomOrder()
|
||||
* which has no notion of that filtering at all — a random pick can never
|
||||
* surface a hidden/unpublished product this way. Meilisearch itself has
|
||||
* no ORDER BY RANDOM() equivalent, so this pulls every matching id only
|
||||
* (attributesToRetrieve: ['id'], the lightest possible request — no
|
||||
* name/media/variants/etc. for documents that will mostly be discarded),
|
||||
* shuffles in PHP, then fetches the full localized documents for just
|
||||
* the $limit ids actually picked.
|
||||
*
|
||||
* @return array<int, array>
|
||||
*/
|
||||
public function random(int $limit): array
|
||||
{
|
||||
$raw = Product::search('')
|
||||
->options(['attributesToRetrieve' => ['id']])
|
||||
->raw();
|
||||
|
||||
$ids = collect($raw['hits'] ?? [])->pluck('id')->shuffle()->take($limit)->values();
|
||||
|
||||
if ($ids->isEmpty()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Meilisearch's `id IN [...]` doesn't preserve the given order — it's
|
||||
// an unordered set filter, not a list to iterate — so the shuffle
|
||||
// above would otherwise be silently undone by whatever order the
|
||||
// re-fetch comes back in. Re-sort the fetched documents back into
|
||||
// $ids's already-shuffled order instead of trusting the response's.
|
||||
$products = collect($this->findAllWhere('id IN ['.$ids->implode(', ').']'))
|
||||
->keyBy('id');
|
||||
|
||||
return $ids->map(fn ($id) => $products->get($id))->filter()->values()->all();
|
||||
}
|
||||
|
||||
private function findOneWhere(string $filter): ?array
|
||||
{
|
||||
$products = $this->findAllWhere($filter, limit: 1);
|
||||
|
||||
return $products[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array>
|
||||
*/
|
||||
private function findAllWhere(string $filter, int $limit = 1000): array
|
||||
{
|
||||
$paginator = Product::search('')
|
||||
->options(['filter' => $filter])
|
||||
->paginateRaw(perPage: 1, page: 1);
|
||||
->paginateRaw(perPage: $limit, page: 1);
|
||||
|
||||
$product = $this->hitsFrom($paginator)[0] ?? null;
|
||||
|
||||
return $product !== null ? $this->withLocalizedFields($product) : null;
|
||||
return collect($this->hitsFrom($paginator))
|
||||
->map(fn (array $product) => $this->withLocalizedFields($product))
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -204,28 +330,4 @@ class ProductService
|
||||
return collect($rawResponse['hits'] ?? [])->values()->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, 'collectionId'|'brand'|'price'|'inStockOnly'> $exclude filter
|
||||
* fields to leave out even if set on $filters — e.g. priceRange() excludes
|
||||
* 'price' so a price slider's own bounds don't shrink to whatever range is
|
||||
* already selected on it.
|
||||
*/
|
||||
private function buildFilter(?ProductFilters $filters, array $exclude = []): ?string
|
||||
{
|
||||
if ($filters === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$clauses = Collection::make([
|
||||
'collectionId' => $filters->collectionId !== null ? "collection_ids = \"{$filters->collectionId}\"" : null,
|
||||
'brand' => $filters->brand !== null ? 'brand = "'.addcslashes($filters->brand, '"\\').'"' : null,
|
||||
'price' => Collection::make([
|
||||
$filters->minPrice !== null ? "price >= {$filters->minPrice}" : null,
|
||||
$filters->maxPrice !== null ? "price <= {$filters->maxPrice}" : null,
|
||||
])->filter()->join(' AND ') ?: null,
|
||||
'inStockOnly' => $filters->inStockOnly ? 'in_stock = true' : null,
|
||||
])->except($exclude)->filter();
|
||||
|
||||
return $clauses->isEmpty() ? null : $clauses->join(' AND ');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Catalog\Support;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
use Modules\Core\Catalog\DTOs\ProductFilters;
|
||||
|
||||
/**
|
||||
* Builds a Meilisearch `filter` clause from a ProductFilters DTO — extracted
|
||||
* out of ProductService (where it originated, scoped to browsing/filtering
|
||||
* without a search term) so ProductSearchService can apply the exact same
|
||||
* filter semantics to a text query too, rather than reimplementing it.
|
||||
*/
|
||||
class ProductFilterBuilder
|
||||
{
|
||||
/**
|
||||
* @param array<int, 'collectionId'|'brand'|'price'|'inStockOnly'> $exclude
|
||||
* filter fields to leave out even if set on $filters — e.g.
|
||||
* ProductService::priceRange() excludes 'price' so a price slider's own
|
||||
* bounds don't shrink to whatever range is already selected on it.
|
||||
*/
|
||||
public function build(?ProductFilters $filters, array $exclude = []): ?string
|
||||
{
|
||||
if ($filters === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$clauses = Collection::make([
|
||||
'collectionId' => $filters->collectionId !== null ? "collection_ids = \"{$filters->collectionId}\"" : null,
|
||||
'brand' => $filters->brand !== null ? 'brand = "'.addcslashes($filters->brand, '"\\').'"' : null,
|
||||
'price' => Collection::make([
|
||||
$filters->minPrice !== null ? "price >= {$filters->minPrice}" : null,
|
||||
$filters->maxPrice !== null ? "price <= {$filters->maxPrice}" : null,
|
||||
])->filter()->join(' AND ') ?: null,
|
||||
'inStockOnly' => $filters->inStockOnly ? 'in_stock = true' : null,
|
||||
])->except($exclude)->filter();
|
||||
|
||||
return $clauses->isEmpty() ? null : $clauses->join(' AND ');
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Checkout\Events;
|
||||
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Lunar\Models\Cart;
|
||||
|
||||
/**
|
||||
* Dispatched by a PaymentDriver once it has independently decided (by
|
||||
* whatever mechanism is native to its gateway) that payment succeeded —
|
||||
* the event-driven counterpart to what used to be a direct
|
||||
* CheckoutService::placeOrder() call from inside confirm(). Listened to by
|
||||
* CheckoutService itself, which places the order and dispatches
|
||||
* OrderPlaced.
|
||||
*
|
||||
* $type/$data are carried through for the same reason PaymentDriver::
|
||||
* confirm() takes them — a driver-specific post-placement step (e.g.
|
||||
* OfflinePaymentDriver's status mapping, StripePaymentDriver's
|
||||
* UpdateOrderFromIntent) still needs them, but can no longer receive the
|
||||
* placed Order as a return value. Each driver instead listens for
|
||||
* OrderPlaced and checks $order->meta['payment_method'] against its own
|
||||
* type(s) to recognize which OrderPlaced is its own — carrying $fingerprint
|
||||
* here too lets a driver correlate its own OrderPlaced listener call back
|
||||
* to the specific confirmation that triggered it, if it needs to.
|
||||
*/
|
||||
class PaymentConfirmed
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
public function __construct(
|
||||
public readonly Cart $cart,
|
||||
public readonly string $type,
|
||||
public readonly string $fingerprint,
|
||||
public readonly array $data = [],
|
||||
) {}
|
||||
}
|
||||
@@ -12,16 +12,15 @@ use Lunar\Facades\ShippingManifest;
|
||||
use Lunar\Models\Cart;
|
||||
use Lunar\Models\Order;
|
||||
use Modules\Core\Cart\Services\CartService;
|
||||
use Modules\Core\Checkout\Contracts\PaymentDriver;
|
||||
use Modules\Core\Checkout\Events\BillingAddressSet;
|
||||
use Modules\Core\Checkout\Events\OrderPlaced;
|
||||
use Modules\Core\Checkout\Events\PaymentConfirmed;
|
||||
use Modules\Core\Checkout\Events\PaymentMethodSelected;
|
||||
use Modules\Core\Checkout\Events\ShippingAddressSet;
|
||||
use Modules\Core\Checkout\Events\ShippingOptionSelected;
|
||||
use Modules\Core\Checkout\Exceptions\InvalidShippingOptionException;
|
||||
use Modules\Core\Checkout\Exceptions\UnknownPaymentTypeException;
|
||||
use Modules\Core\Payment\Models\PaymentMethod;
|
||||
use Modules\Core\Payment\Services\PaymentDriverResolver;
|
||||
|
||||
/**
|
||||
* Storefront-facing checkout operations, mirroring
|
||||
@@ -42,7 +41,6 @@ class CheckoutService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CartService $cart,
|
||||
private readonly PaymentDriverResolver $paymentDrivers,
|
||||
) {}
|
||||
|
||||
public function setShippingAddress(array|Addressable $address): Cart
|
||||
@@ -151,7 +149,7 @@ class CheckoutService
|
||||
{
|
||||
return PaymentMethod::where('enabled', true)
|
||||
->pluck('type')
|
||||
->filter(fn (string $type) => $this->paymentDrivers->resolve($type)?->isConfigured() ?? false)
|
||||
->filter(fn (string $type) => $this->resolvePaymentDriver($type)?->isConfigured() ?? false)
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
@@ -200,18 +198,11 @@ class CheckoutService
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves $type's registered PaymentDriver and calls confirm() — the
|
||||
* driver independently decides whether payment succeeded and, if so,
|
||||
* dispatches PaymentConfirmed (see PaymentDriver's docblock) rather
|
||||
* than placing the order itself or returning it here. This method is
|
||||
* fire-and-forget as far as the Order is concerned: a caller that
|
||||
* needs it back listens for OrderPlaced, the same way a driver's own
|
||||
* post-placement step does — see PaymentConfirmed's docblock for why a
|
||||
* direct return value doesn't fit every gateway (async/webhook-driven
|
||||
* confirmations have no synchronous caller waiting for one at all).
|
||||
*
|
||||
* $data carries whatever that driver needs (Stripe's payment_intent
|
||||
* id, a future redirect-based provider's callback payload).
|
||||
* Resolves $type's registered PaymentDriver and calls confirm() —
|
||||
* the driver decides whether/when the order actually gets placed (see
|
||||
* Modules\Core\Checkout\Contracts\PaymentDriver's docblock). $data
|
||||
* carries whatever that driver needs (Stripe's payment_intent id, a
|
||||
* future redirect-based provider's callback payload).
|
||||
*
|
||||
* The fingerprint passed to the driver is the one captured by
|
||||
* selectPaymentMethod(), not supplied by the caller — see that
|
||||
@@ -229,7 +220,7 @@ class CheckoutService
|
||||
* @throws \Lunar\Exceptions\FingerprintMismatchException
|
||||
* @throws \Lunar\Exceptions\Carts\CartException
|
||||
*/
|
||||
public function confirmPayment(string $type, array $data = []): void
|
||||
public function confirmPayment(string $type, array $data = []): Order
|
||||
{
|
||||
if (! in_array($type, $this->getPaymentMethods(), true)) {
|
||||
throw new UnknownPaymentTypeException($type);
|
||||
@@ -238,6 +229,20 @@ class CheckoutService
|
||||
$cart = $this->cart->currentOrCreate();
|
||||
$fingerprint = $cart->meta['checkout_fingerprint'] ?? '';
|
||||
|
||||
$this->paymentDrivers->resolve($type)->confirm($cart, $type, $fingerprint, $data);
|
||||
return $this->resolvePaymentDriver($type)->confirm($cart, $type, $fingerprint, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves $type's registered PaymentDriver, or null if $type has no
|
||||
* 'payment_driver' registered in config('lunar.payments.types.<type>')
|
||||
* at all — deliberately non-throwing so getPaymentMethods() can filter
|
||||
* unresolvable types silently rather than treating "not registered"
|
||||
* as an error condition when just checking availability.
|
||||
*/
|
||||
private function resolvePaymentDriver(string $type): ?PaymentDriver
|
||||
{
|
||||
$driverClass = config("lunar.payments.types.{$type}.payment_driver");
|
||||
|
||||
return $driverClass ? app($driverClass) : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Command;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Laravel\Scout\EngineManager;
|
||||
use Laravel\Scout\Engines\MeilisearchEngine;
|
||||
use Lunar\Models\Product;
|
||||
|
||||
/**
|
||||
* lunarphp/meilisearch's own `lunar:meilisearch:setup` only pushes
|
||||
* filterableAttributes/sortableAttributes (see MeilisearchSetup::handle())
|
||||
* — it has no notion of typo tolerance or prefix search, and Meilisearch's
|
||||
* defaults for both are loose enough to produce bad matches on short Greek
|
||||
* words. Confirmed via showMatchesPosition that a query for "Κάπτεν" was
|
||||
* matching "κανένας" purely through prefixSearch's default 'indexingTime'
|
||||
* behavior (their edit distance is far past anything typo tolerance would
|
||||
* bridge) — fixed by disabling prefix search below, verified afterward with
|
||||
* "Super"/"Superheroes"-style prefix probes returning no results for a
|
||||
* partial word. minWordSizeForTypos is tightened defensively alongside it
|
||||
* so short words in general get less typo-tolerant fuzzing, even though a
|
||||
* separate short-word collision case ("Κάπτεν" vs "κάποτε", high letter
|
||||
* overlap despite real edit distance) persisted after both settings were
|
||||
* confirmed live and wasn't fully root-caused — treated as a known,
|
||||
* narrow edge case rather than a blocker. Run this after
|
||||
* `lunar:meilisearch:setup`, whenever Product's index needs
|
||||
* (re)provisioning.
|
||||
*
|
||||
* Disabling prefix search here is a deliberate tradeoff: it also turns off
|
||||
* legitimate partial-word matching (typing "car" matching "cart" before
|
||||
* you finish the word) — useful for a future autocomplete/search-as-you-
|
||||
* type UI. If that's built later, re-enable prefixSearch deliberately then,
|
||||
* informed by real UX needs, rather than leaving it on by accident today.
|
||||
*/
|
||||
class TuneProductSearchCommand extends Command
|
||||
{
|
||||
protected $signature = 'lunar:meilisearch:tune-product-search';
|
||||
|
||||
protected $description = 'Tighten typo-tolerance and disable prefix search on the product search index';
|
||||
|
||||
public function handle(EngineManager $engineManager): void
|
||||
{
|
||||
/** @var MeilisearchEngine $engine */
|
||||
$engine = $engineManager->createMeilisearchDriver();
|
||||
|
||||
$index = $engine->getIndex((new Product)->searchableAs());
|
||||
|
||||
$this->components->info('Updating typo tolerance for product search...');
|
||||
|
||||
$task = $index->updateTypoTolerance([
|
||||
'minWordSizeForTypos' => [
|
||||
'oneTypo' => 8,
|
||||
'twoTypos' => 12,
|
||||
],
|
||||
]);
|
||||
|
||||
$engine->waitForTask($task['taskUid']);
|
||||
|
||||
$this->components->info('Disabling prefix search for product search...');
|
||||
|
||||
$task = $index->updatePrefixSearch('disabled');
|
||||
|
||||
$engine->waitForTask($task['taskUid']);
|
||||
|
||||
$this->components->info('Product search index tuned.');
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Payment\Contracts;
|
||||
|
||||
use Modules\Core\Payment\DataTransferObjects\PaymentInitiation;
|
||||
|
||||
/**
|
||||
* The synchronous half of a payment driver — every driver implements this,
|
||||
* since every provider has some notion of "start a payment," even if (like
|
||||
* an offline/cash type) there's no real gateway round-trip involved.
|
||||
*
|
||||
* This is deliberately synchronous, unlike the rest of the payment
|
||||
* lifecycle: a storefront request needing a redirect URL, or frontend JS
|
||||
* needing a client secret to render an embedded payment form, has nothing
|
||||
* to redirect to or render until initiate() returns — there is no event
|
||||
* that can hand a mid-request controller a value it needs for its own HTTP
|
||||
* response. Everything after this point (the payment actually completing,
|
||||
* failing, a chargeback) is genuinely async and belongs on
|
||||
* HandlesPaymentCallback / PaymentSucceeded / PaymentFailed instead.
|
||||
*/
|
||||
interface InitiatesPayment
|
||||
{
|
||||
/**
|
||||
* Whether this driver can actually be used right now — e.g. checking
|
||||
* an API key is configured. Independent of
|
||||
* Modules\Core\Payment\Models\PaymentMethod::enabled (the admin
|
||||
* on/off toggle).
|
||||
*/
|
||||
public function isConfigured(): bool;
|
||||
|
||||
/**
|
||||
* $type is the payment type key being initiated (e.g.
|
||||
* 'cash-on-delivery', 'viva', 'stripe') — passed through even though
|
||||
* most drivers only ever serve one type, because a driver shared
|
||||
* across several types needs it to look up that type's own config.
|
||||
*
|
||||
* $data carries whatever the gateway needs to start this payment
|
||||
* (amount, currency, return/webhook URLs, customer details) — the
|
||||
* caller's responsibility to assemble, since a driver has no notion
|
||||
* of a cart or order to pull them from itself.
|
||||
*
|
||||
* $context is opaque to the driver (see PaymentDriver — actually
|
||||
* PaymentSucceeded's docblock — for the full reasoning): carried
|
||||
* through untouched into whatever PaymentSucceeded/PaymentFailed this
|
||||
* payment eventually produces, so the caller can correlate the result
|
||||
* back to whatever it needs (a cart id and fingerprint, for
|
||||
* Checkout), without this driver or Payment generally needing to know
|
||||
* what that is.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function initiate(string $type, array $data, array $context = []): PaymentInitiation;
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Payment\Contracts;
|
||||
|
||||
use Lunar\Exceptions\FingerprintMismatchException;
|
||||
use Lunar\Exceptions\Carts\CartException;
|
||||
use Lunar\Models\Cart;
|
||||
|
||||
/**
|
||||
* A boboko-owned payment driver — wraps a payment gateway's own confirmation
|
||||
* mechanics (Stripe's synchronous authorize() call, a redirect-based
|
||||
* provider's async callback/webhook, anything else) behind one uniform
|
||||
* moment: "payment is confirmed."
|
||||
*
|
||||
* confirm() is the only thing a driver is required to do: once it has,
|
||||
* by whatever mechanism is native to that gateway, independently decided
|
||||
* the payment succeeded, it dispatches Modules\Core\Checkout\Events\
|
||||
* PaymentConfirmed — no driver ever calls CheckoutService::placeOrder() or
|
||||
* Lunar\Models\Cart::createOrder() directly. CheckoutService itself listens
|
||||
* for PaymentConfirmed and places the order from there; a driver that needs
|
||||
* to do something to the placed Order afterward (status mapping, syncing
|
||||
* gateway state) listens for the resulting OrderPlaced itself, matching it
|
||||
* via $order->meta['payment_method'] — see PaymentConfirmed's docblock for
|
||||
* why. This split is what makes an async/webhook-driven gateway (payment
|
||||
* confirmed in a request that has no synchronous caller waiting for an
|
||||
* Order at all) and a synchronous one (Stripe) work through the exact same
|
||||
* contract. See docs/checkout.md / docs/payments.md.
|
||||
*/
|
||||
interface PaymentDriver
|
||||
{
|
||||
/**
|
||||
* Whether this driver can actually be used right now — e.g. Stripe
|
||||
* checking its own API key is present, an offline-style driver always
|
||||
* returning true since it has no external dependency. Independent of
|
||||
* Modules\Core\Payment\Models\PaymentMethod::enabled (the admin
|
||||
* on/off toggle) — CheckoutService::getPaymentMethods() combines both:
|
||||
* a type is only offered to the storefront if it's administratively
|
||||
* enabled AND its driver reports itself configured.
|
||||
*/
|
||||
public function isConfigured(): bool;
|
||||
|
||||
/**
|
||||
* $type is the payment type key being confirmed (e.g. 'cash-in-hand',
|
||||
* 'cash-on-delivery', 'stripe') — passed through even though most
|
||||
* drivers only ever serve one type, because a driver shared across
|
||||
* several types (e.g. one "no real confirmation" offline driver behind
|
||||
* both cash-in-hand and cash-on-delivery) needs it to look up that
|
||||
* type's own config (e.g. its 'authorized' status) rather than another
|
||||
* type's.
|
||||
*
|
||||
* $data carries whatever the gateway needs to confirm this specific
|
||||
* payment (Stripe: ['payment_intent' => $id], a redirect-based
|
||||
* provider: its callback payload) — passed explicitly by the caller
|
||||
* (a controller, a webhook job) rather than a driver reaching into the
|
||||
* global request(), so confirm() works the same whether it's called
|
||||
* from a synchronous HTTP request or an async webhook/job with no
|
||||
* active request at all.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*
|
||||
* @throws FingerprintMismatchException
|
||||
* @throws CartException
|
||||
*/
|
||||
public function confirm(Cart $cart, string $type, string $fingerprint, array $data): void;
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Payment\Contracts;
|
||||
|
||||
use Lunar\Models\Order;
|
||||
use Modules\Core\Payment\DataTransferObjects\CaptureResult;
|
||||
|
||||
/**
|
||||
* Optional capability for payment drivers whose gateway supports a
|
||||
* separate authorize-then-capture step. Many redirect/wallet-style
|
||||
* gateways (Viva Wallet included, for most flows) charge in full at
|
||||
* checkout and never need this — SupportsRefunds is the one they're more
|
||||
* likely to implement instead.
|
||||
*/
|
||||
interface SupportsCaptures
|
||||
{
|
||||
/**
|
||||
* $reference is the gateway's own identifier for the authorized charge
|
||||
* — see SupportsRefunds::refund() for why this isn't a Lunar
|
||||
* Transaction. $amount is in the currency's minor unit.
|
||||
*/
|
||||
public function capture(Order $order, string $reference, int $amount, ?string $notes = null): CaptureResult;
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Payment\Contracts;
|
||||
|
||||
use Lunar\Models\Order;
|
||||
use Modules\Core\Payment\DataTransferObjects\RefundResult;
|
||||
|
||||
/**
|
||||
* Optional capability for payment drivers whose gateway supports refunding
|
||||
* a prior charge. Drivers without a refund API (or that never got that far
|
||||
* — e.g. an offline/manual driver) simply don't implement it. Mirrors
|
||||
* Shipping\Contracts\SupportsTracking's opt-in shape.
|
||||
*/
|
||||
interface SupportsRefunds
|
||||
{
|
||||
/**
|
||||
* $reference is the gateway's own identifier for the charge being
|
||||
* refunded (e.g. a Viva Wallet transaction id) — not a Lunar
|
||||
* Transaction model, since not every gateway's refund flow maps
|
||||
* cleanly onto one. $amount is in the currency's minor unit, same
|
||||
* convention as Lunar\Base\Casts\Price.
|
||||
*/
|
||||
public function refund(Order $order, string $reference, int $amount, ?string $notes = null): RefundResult;
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Payment\DataTransferObjects;
|
||||
|
||||
/**
|
||||
* Returned by SupportsCaptures::capture() — see RefundResult for why this
|
||||
* carries nothing Lunar-shaped.
|
||||
*/
|
||||
class CaptureResult
|
||||
{
|
||||
public function __construct(
|
||||
public readonly bool $success,
|
||||
public readonly int $amount,
|
||||
public readonly ?string $reference = null,
|
||||
public readonly ?string $message = null,
|
||||
public readonly array $meta = [],
|
||||
) {}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Payment\DataTransferObjects;
|
||||
|
||||
use Modules\Core\Payment\Enums\PaymentInitiationMode;
|
||||
|
||||
/**
|
||||
* Returned by InitiatesPayment::initiate() — the one thing a caller needs
|
||||
* synchronously, in the same request, regardless of which provider is
|
||||
* behind it. redirectUrl/clientSecret are mutually exclusive in practice
|
||||
* (only the one matching $mode is ever set) but both nullable rather than
|
||||
* split into per-mode subclasses — see PaymentInitiationMode for why.
|
||||
*
|
||||
* $reference is the gateway's own identifier for this payment attempt
|
||||
* (an order/session/intent id) — the same value HandlesPaymentCallback's
|
||||
* driver will later see again in the callback payload, and what
|
||||
* PaymentSucceeded/PaymentFailed carry forward. A driver in Immediate
|
||||
* mode still returns one, even though there's no callback to correlate
|
||||
* against, since it's also what gets recorded as the Transaction's
|
||||
* reference.
|
||||
*/
|
||||
class PaymentInitiation
|
||||
{
|
||||
public function __construct(
|
||||
public readonly PaymentInitiationMode $mode,
|
||||
public readonly string $reference,
|
||||
public readonly ?string $redirectUrl = null,
|
||||
public readonly ?string $clientSecret = null,
|
||||
public readonly array $meta = [],
|
||||
) {}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Payment\DataTransferObjects;
|
||||
|
||||
/**
|
||||
* Returned by SupportsRefunds::refund() — gateway-agnostic, carries nothing
|
||||
* Lunar-shaped (no Transaction, no Lunar DTO). TransactionRecorder turns
|
||||
* this into a Transaction row afterward; the driver itself never writes
|
||||
* one.
|
||||
*/
|
||||
class RefundResult
|
||||
{
|
||||
public function __construct(
|
||||
public readonly bool $success,
|
||||
public readonly int $amount,
|
||||
public readonly ?string $reference = null,
|
||||
public readonly ?string $message = null,
|
||||
public readonly array $meta = [],
|
||||
) {}
|
||||
}
|
||||
@@ -2,28 +2,35 @@
|
||||
|
||||
namespace Modules\Core\Payment\Drivers;
|
||||
|
||||
use Lunar\Exceptions\Carts\CartException;
|
||||
use Lunar\Exceptions\DisallowMultipleCartOrdersException;
|
||||
use Lunar\Exceptions\FingerprintMismatchException;
|
||||
use Lunar\Models\Cart;
|
||||
use Modules\Core\Checkout\Events\OrderPlaced;
|
||||
use Modules\Core\Checkout\Events\PaymentConfirmed;
|
||||
use Modules\Core\Payment\Contracts\PaymentDriver;
|
||||
use Lunar\Models\Order;
|
||||
use Modules\Core\Checkout\Contracts\PaymentDriver;
|
||||
use Modules\Core\Checkout\Services\CheckoutService;
|
||||
|
||||
/**
|
||||
* Shared by every payment type with no real gateway to confirm against —
|
||||
* cash-in-hand, cash-on-delivery — where the shopper pays at pickup/on
|
||||
* delivery, not at checkout. confirm() has nothing to wait on, so it
|
||||
* dispatches PaymentConfirmed immediately, same moment Lunar's own
|
||||
* OfflinePayment would place the order — but the actual placement now
|
||||
* happens in CheckoutService::onPaymentConfirmed(), not here. $data is
|
||||
* unused: nothing about this confirmation depends on gateway-specific
|
||||
* payload.
|
||||
* delivery, not at checkout. confirm() has nothing to wait on, so it places
|
||||
* the order immediately, same as Lunar's own OfflinePayment would, but
|
||||
* through CheckoutService::placeOrder() so it goes through the same
|
||||
* fingerprint check every other driver does. $data is unused: nothing about
|
||||
* this confirmation depends on gateway-specific payload.
|
||||
*
|
||||
* The status-mapping step this driver used to do inline right after
|
||||
* placeOrder() returned now happens in onOrderPlaced() below instead —
|
||||
* see PaymentDriver's docblock for why a driver can no longer rely on
|
||||
* placeOrder()'s return value.
|
||||
* Sets the order status to config("lunar.payments.types.{$type}.authorized")
|
||||
* afterward, using the type actually confirmed — not a hardcoded key —
|
||||
* since this one driver is shared across multiple types.
|
||||
* placeOrder() itself leaves the order at Lunar's configured draft_status,
|
||||
* same as every driver is responsible for moving it on from.
|
||||
*/
|
||||
class OfflinePaymentDriver implements PaymentDriver
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CheckoutService $checkout,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Always true — no external dependency to be missing.
|
||||
*/
|
||||
@@ -32,30 +39,19 @@ class OfflinePaymentDriver implements PaymentDriver
|
||||
return true;
|
||||
}
|
||||
|
||||
public function confirm(Cart $cart, string $type, string $fingerprint, array $data): void
|
||||
{
|
||||
PaymentConfirmed::dispatch($cart, $type, $fingerprint, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registered in PaymentServiceProvider. Every offline-style type
|
||||
* shares this one driver, so $order->meta['payment_method'] is checked
|
||||
* against config('lunar.payments.types') to confirm the placed order
|
||||
* actually belongs to one of them, rather than assuming every
|
||||
* OrderPlaced is this driver's to act on — a Stripe order placed via
|
||||
* StripePaymentDriver fires the same event.
|
||||
* @throws FingerprintMismatchException
|
||||
* @throws CartException
|
||||
* @throws DisallowMultipleCartOrdersException
|
||||
*/
|
||||
public function onOrderPlaced(OrderPlaced $event): void
|
||||
public function confirm(Cart $cart, string $type, string $fingerprint, array $data): Order
|
||||
{
|
||||
$order = $event->order;
|
||||
$type = $order->meta['payment_method'] ?? null;
|
||||
|
||||
if (! $type || config("lunar.payments.types.{$type}.payment_driver") !== self::class) {
|
||||
return;
|
||||
}
|
||||
$order = $this->checkout->placeOrder($fingerprint);
|
||||
|
||||
$order->update([
|
||||
'status' => config("lunar.payments.types.{$type}.authorized", $order->status),
|
||||
]);
|
||||
|
||||
return $order->refresh();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,40 +2,39 @@
|
||||
|
||||
namespace Modules\Core\Payment\Drivers;
|
||||
|
||||
use Lunar\Exceptions\FingerprintMismatchException;
|
||||
use Lunar\Exceptions\Carts\CartException;
|
||||
use Lunar\Exceptions\DisallowMultipleCartOrdersException;
|
||||
use Lunar\Models\Cart;
|
||||
use Lunar\Models\Order;
|
||||
use Lunar\Stripe\Actions\UpdateOrderFromIntent;
|
||||
use Lunar\Stripe\Facades\Stripe;
|
||||
use Lunar\Stripe\Models\StripePaymentIntent;
|
||||
use Modules\Core\Checkout\Events\OrderPlaced;
|
||||
use Modules\Core\Checkout\Events\PaymentConfirmed;
|
||||
use Modules\Core\Payment\Contracts\PaymentDriver;
|
||||
use Modules\Core\Checkout\Contracts\PaymentDriver;
|
||||
use Modules\Core\Checkout\Services\CheckoutService;
|
||||
use Modules\Core\Payment\Exceptions\PaymentNotConfirmedException;
|
||||
use Stripe\PaymentIntent;
|
||||
|
||||
/**
|
||||
* Wraps Lunar\Stripe\StripePaymentType::authorize() to satisfy
|
||||
* Modules\Core\Payment\Contracts\PaymentDriver — dispatches
|
||||
* PaymentConfirmed at the moment Stripe confirms payment, instead of the
|
||||
* vendor's own Cart::createOrder() call.
|
||||
* Modules\Core\Checkout\Contracts\PaymentDriver — calls
|
||||
* CheckoutService::placeOrder($fingerprint) at the moment Stripe confirms
|
||||
* payment, instead of the vendor's own Cart::createOrder() call.
|
||||
*
|
||||
* This is a fork, not a decoration: StripePaymentType::authorize() is
|
||||
* `final` and calls Cart::createOrder() directly with no seam to redirect
|
||||
* that one call — so this class reimplements authorize()'s logic (intent
|
||||
* retrieval, capture-on-policy) rather than wrapping the vendor method.
|
||||
* Kept deliberately close to the original so a lunarphp/stripe upgrade is
|
||||
* easy to diff against. See docs/payments.md.
|
||||
*
|
||||
* The status-mapping step (UpdateOrderFromIntent) this driver used to do
|
||||
* inline right after placeOrder() returned now happens in onOrderPlaced()
|
||||
* below instead — see PaymentDriver's docblock for why a driver can no
|
||||
* longer rely on placeOrder()'s return value. Since that step needs the
|
||||
* live Stripe PaymentIntent, not just the Order, onOrderPlaced() re-fetches
|
||||
* it from Stripe via the StripePaymentIntent row this method already wrote
|
||||
* (keyed by the order's cart_id) rather than carrying the PaymentIntent
|
||||
* object across the event boundary itself.
|
||||
* retrieval, capture-on-policy, status mapping via UpdateOrderFromIntent)
|
||||
* rather than wrapping the vendor method. Kept deliberately close to the
|
||||
* original so a lunarphp/stripe upgrade is easy to diff against. See
|
||||
* docs/payments.md.
|
||||
*/
|
||||
class StripePaymentDriver implements PaymentDriver
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CheckoutService $checkout,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Same key lunarphp/stripe's own StripeManager reads its API key from
|
||||
* (Stripe::setApiKey(config('services.stripe.key')) in
|
||||
@@ -48,11 +47,13 @@ class StripePaymentDriver implements PaymentDriver
|
||||
|
||||
/**
|
||||
* @throws PaymentNotConfirmedException if Stripe hasn't confirmed the
|
||||
* payment intent (wrong intent id, already processed, or the gateway
|
||||
* call itself fails) — nothing here should be treated as "confirm
|
||||
* anyway."
|
||||
* payment intent (wrong intent id, already processed, order already
|
||||
* placed, or the gateway call itself fails) — nothing here should be
|
||||
* treated as "place the order anyway."
|
||||
* @throws FingerprintMismatchException
|
||||
* @throws CartException
|
||||
*/
|
||||
public function confirm(Cart $cart, string $type, string $fingerprint, array $data): void
|
||||
public function confirm(Cart $cart, string $type, string $fingerprint, array $data): Order
|
||||
{
|
||||
$paymentIntentId = $data['payment_intent'];
|
||||
|
||||
@@ -92,34 +93,19 @@ class StripePaymentDriver implements PaymentDriver
|
||||
);
|
||||
}
|
||||
|
||||
$paymentIntentModel->status = $paymentIntent->status;
|
||||
$paymentIntentModel->save();
|
||||
|
||||
PaymentConfirmed::dispatch($cart, $type, $fingerprint, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registered in PaymentServiceProvider. Matches via the order's
|
||||
* cart_id against the StripePaymentIntent row confirm() wrote, so a
|
||||
* non-Stripe OrderPlaced (offline types fire the same event) is
|
||||
* ignored rather than acted on.
|
||||
*/
|
||||
public function onOrderPlaced(OrderPlaced $event): void
|
||||
{
|
||||
$order = $event->order;
|
||||
|
||||
$paymentIntentModel = StripePaymentIntent::where('cart_id', $order->cart_id)->first();
|
||||
|
||||
if (! $paymentIntentModel) {
|
||||
return;
|
||||
try {
|
||||
$order = $this->checkout->placeOrder($fingerprint);
|
||||
} catch (DisallowMultipleCartOrdersException|CartException $e) {
|
||||
throw new PaymentNotConfirmedException($e->getMessage(), previous: $e);
|
||||
}
|
||||
|
||||
$paymentIntentModel->order_id = $order->id;
|
||||
$paymentIntentModel->status = $paymentIntent->status;
|
||||
$paymentIntentModel->processed_at = now();
|
||||
$paymentIntentModel->save();
|
||||
|
||||
$paymentIntent = Stripe::getClient()->paymentIntents->retrieve($paymentIntentModel->intent_id);
|
||||
|
||||
UpdateOrderFromIntent::execute($order, $paymentIntent);
|
||||
|
||||
return $order->refresh();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Payment\Enums;
|
||||
|
||||
/**
|
||||
* What a caller of InitiatesPayment::initiate() needs to do right now with
|
||||
* the PaymentInitiation it got back.
|
||||
*/
|
||||
enum PaymentInitiationMode: string
|
||||
{
|
||||
/**
|
||||
* Send the shopper to redirectUrl (Viva, Klarna, EasyPay-style
|
||||
* redirect flows) — they leave the site, pay, and return via a
|
||||
* callback/webhook the driver handles separately.
|
||||
*/
|
||||
case Redirect = 'redirect';
|
||||
|
||||
/**
|
||||
* Hand clientSecret to frontend JS, which completes payment in-page
|
||||
* (Stripe Elements, Nexi hosted fields) — no redirect away from the
|
||||
* site.
|
||||
*/
|
||||
case ClientSecret = 'client_secret';
|
||||
|
||||
/**
|
||||
* Nothing further to do — the driver has already dispatched
|
||||
* PaymentSucceeded (or will throw) by the time initiate() returns.
|
||||
* Offline/no-gateway types (cash-on-delivery) are always this mode:
|
||||
* there's no gateway round-trip to wait on.
|
||||
*/
|
||||
case Immediate = 'immediate';
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Payment\Events;
|
||||
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
|
||||
/**
|
||||
* Dispatched by a PaymentDriver once it has independently decided (by
|
||||
* whatever mechanism is native to its gateway) that a payment succeeded.
|
||||
* Deliberately carries nothing but what a payment fundamentally is —
|
||||
* $type, $reference, $amount — plus $context, an opaque bag the driver
|
||||
* received from whoever called confirm() and hands back unchanged here.
|
||||
*
|
||||
* Payment has no concept of a cart, an order, or a checkout fingerprint —
|
||||
* those are Checkout's concepts, and Checkout is only one possible
|
||||
* consumer of a successful payment (a future Subscriptions module renewing
|
||||
* on a recurring charge is another). $context is how a caller like
|
||||
* CheckoutService::confirmPayment() smuggles what it needs to react
|
||||
* (cart_id, fingerprint) through Payment without Payment ever reading or
|
||||
* caring what's inside — each listener interprets $context on its own
|
||||
* terms, or ignores the event entirely if the keys it needs aren't there.
|
||||
*/
|
||||
class PaymentSucceeded
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
/**
|
||||
* $amount is in the currency's minor unit, same convention as
|
||||
* Lunar\Base\Casts\Price.
|
||||
*
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $type,
|
||||
public readonly string $reference,
|
||||
public readonly int $amount,
|
||||
public readonly array $context = [],
|
||||
) {}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Thrown by a Modules\Core\Payment\Contracts\PaymentDriver when the
|
||||
* Thrown by a Modules\Core\Checkout\Contracts\PaymentDriver when the
|
||||
* gateway has not confirmed payment — wrong/expired intent, already
|
||||
* processed, or the gateway itself rejects the confirmation. A driver
|
||||
* throws this instead of silently placing the order: CheckoutService::
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Payment\Listeners;
|
||||
|
||||
use Modules\Core\Checkout\Events\OrderPlaced;
|
||||
use Modules\Core\Payment\Drivers\OfflinePaymentDriver;
|
||||
|
||||
/**
|
||||
* The status-mapping step OfflinePaymentDriver used to do inline right
|
||||
* after CheckoutService::placeOrder() returned — moved out to a listener
|
||||
* since confirm() can no longer rely on that return value (see
|
||||
* PaymentDriver's docblock).
|
||||
*
|
||||
* Every offline-style type shares OfflinePaymentDriver, so
|
||||
* $order->meta['payment_method'] is checked against
|
||||
* config('lunar.payments.types') to confirm the placed order actually
|
||||
* belongs to one of them, rather than assuming every OrderPlaced is
|
||||
* this listener's to act on — a Stripe order placed via
|
||||
* StripePaymentDriver fires the same event.
|
||||
*/
|
||||
class ApplyOfflinePaymentStatus
|
||||
{
|
||||
public function handle(OrderPlaced $event): void
|
||||
{
|
||||
$order = $event->order;
|
||||
$type = $order->meta['payment_method'] ?? null;
|
||||
|
||||
if (! $type || config("lunar.payments.types.{$type}.payment_driver") !== OfflinePaymentDriver::class) {
|
||||
return;
|
||||
}
|
||||
|
||||
$order->update([
|
||||
'status' => config("lunar.payments.types.{$type}.authorized", $order->status),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Payment\Services;
|
||||
|
||||
use Modules\Core\Payment\Contracts\PaymentDriver;
|
||||
|
||||
/**
|
||||
* Resolves a payment type key (e.g. 'stripe', 'cash-on-delivery') to its
|
||||
* registered PaymentDriver — extracted out of CheckoutService so both it
|
||||
* and anything else needing the same lookup (e.g. a listener reacting to
|
||||
* OrderPlaced, which has no reason to depend on Checkout's own service)
|
||||
* share one implementation instead of duplicating this config read.
|
||||
*/
|
||||
class PaymentDriverResolver
|
||||
{
|
||||
/**
|
||||
* Null if $type has no 'payment_driver' registered in
|
||||
* config('lunar.payments.types.<type>') at all — deliberately
|
||||
* non-throwing so a caller like CheckoutService::getPaymentMethods()
|
||||
* can filter unresolvable types silently rather than treating "not
|
||||
* registered" as an error condition when just checking availability.
|
||||
*/
|
||||
public function resolve(string $type): ?PaymentDriver
|
||||
{
|
||||
$driverClass = config("lunar.payments.types.{$type}.payment_driver");
|
||||
|
||||
return $driverClass ? app($driverClass) : null;
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Payment\Services;
|
||||
|
||||
use Lunar\Models\Order;
|
||||
use Lunar\Models\Transaction;
|
||||
use Modules\Core\Payment\DataTransferObjects\CaptureResult;
|
||||
use Modules\Core\Payment\DataTransferObjects\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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ use Modules\Core\Command\ExportCommand;
|
||||
use Modules\Core\Command\ImportCommand;
|
||||
use Modules\Core\Command\InstallLunarCommand;
|
||||
use Modules\Core\Command\MigrateImportCommand;
|
||||
use Modules\Core\Command\TuneProductSearchCommand;
|
||||
|
||||
class CoreServiceProvider extends ServiceProvider
|
||||
{
|
||||
@@ -35,7 +36,7 @@ class CoreServiceProvider extends ServiceProvider
|
||||
], 'core-assets');
|
||||
|
||||
if ($this->app->runningInConsole()) {
|
||||
$this->commands([AnonymizeCommand::class, ExportCommand::class, ExportCleanupCommand::class, ImportCommand::class, MigrateImportCommand::class]);
|
||||
$this->commands([AnonymizeCommand::class, ExportCommand::class, ExportCleanupCommand::class, ImportCommand::class, MigrateImportCommand::class, TuneProductSearchCommand::class]);
|
||||
|
||||
//Overriding lunar:install
|
||||
$this->app->booted(fn () => $this->commands([InstallLunarCommand::class]));
|
||||
|
||||
Reference in New Issue
Block a user