Compare commits

..
1 Commits
Author SHA1 Message Date
arvanitakis 8cb54e065e Feat: Updates to Payments, Checkout Services, Payment Events 2026-09-02 16:14:52 +03:00
29 changed files with 640 additions and 526 deletions
-16
View File
@@ -4,22 +4,6 @@ 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/). 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 ## [0.11.1] - 2026-09-01
### Fixed ### Fixed
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "boboko/core", "name": "boboko/core",
"description": "Core module — authentication and shared panel behaviour", "description": "Core module — authentication and shared panel behaviour",
"type": "library", "type": "library",
"version": "0.12.0", "version": "0.11.1",
"autoload": { "autoload": {
"psr-4": { "psr-4": {
"Modules\\Core\\": "src/" "Modules\\Core\\": "src/"
+1 -1
View File
@@ -14,7 +14,7 @@ return [
| Lunar's own config. | Lunar's own config.
| |
| 'payment_driver' is boboko-owned, alongside Lunar's own 'driver' key — | 'payment_driver' is boboko-owned, alongside Lunar's own 'driver' key —
| it's the Modules\Core\Checkout\Contracts\PaymentDriver class | it's the Modules\Core\Payment\Contracts\PaymentDriver class
| CheckoutService::confirmPayment() resolves via the container and calls | CheckoutService::confirmPayment() resolves via the container and calls
| confirm() on. Kept on the same row as 'driver' rather than a second, | 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, | separately-keyed map, so a type's full definition — Lunar's driver,
+15 -44
View File
@@ -1,9 +1,8 @@
# Product Listing # Product Listing
`Modules\Core\Catalog\Services\ProductService` provides catalog browsing/filtering AND single-product `Modules\Core\Catalog\Services\ProductService` provides catalog browsing/filtering AND single-product
lookup for a storefront — `list()`, `getById()`, `getBySlug()`, `random()`, `variantSummaries()` — lookup for a storefront — `list()`, `getById()`, `getBySlug()` — all reading directly from the
all reading directly from the Meilisearch index rather than the database. One data source for Meilisearch index rather than the database. One data source for everything this service does.
everything this service does.
This is separate from `Modules\Core\Catalog\Services\ProductSearchService` (see `product-search.md`), which 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. handles free-text query search. `ProductService` is for browsing/lookup without a search term.
@@ -29,14 +28,13 @@ use Modules\Core\Catalog\Enums\ProductSort;
$service = app(ProductService::class); $service = app(ProductService::class);
// One call for everything a listing page needs — products AND the price slider's // List everything, paginated — returns a real Illuminate\Pagination\LengthAwarePaginator,
// bounds together, as a Modules\Core\Catalog\DTOs\ProductListingResult. A caller // built from the localized Meilisearch hits (not Scout's own paginateRaw() result — see
// used to have to call list() and priceSliderBounds() (or the older priceRange()) // "Meilisearch driver quirk" below), so it behaves like any other Laravel paginator.
// separately and glue the results together itself; that's now list()'s own job. $products = $service->list(perPage: 24, page: 1);
$listing = $service->list(perPage: 24, page: 1);
// Filter by collection, brand, price range, and/or stock // Filter by collection, brand, price range, and/or stock
$listing = $service->list( $products = $service->list(
filters: new ProductFilters(collectionId: 17, minPrice: 10.0, maxPrice: 50.0, inStockOnly: true), filters: new ProductFilters(collectionId: 17, minPrice: 10.0, maxPrice: 50.0, inStockOnly: true),
perPage: 24, perPage: 24,
page: 1, page: 1,
@@ -44,13 +42,8 @@ $listing = $service->list(
// Sort — cheapest/priciest first, or newest first. Omit for Meilisearch's default // Sort — cheapest/priciest first, or newest first. Omit for Meilisearch's default
// relevance ordering (irrelevant here since the query is always empty). // relevance ordering (irrelevant here since the query is always empty).
$listing = $service->list(perPage: 24, page: 1, sort: ProductSort::PriceAsc); $products = $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->items(); // array of Meilisearch documents (plain arrays, not models)
$products->total(); $products->total();
$products->perPage(); $products->perPage();
@@ -58,32 +51,12 @@ $products->currentPage();
$products->lastPage(); $products->lastPage();
$products->links(); // in a Blade view — renders pagination links as usual $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 // Single product, by primary key
$product = $service->getById(367); // array, or null if not found $product = $service->getById(367); // array, or null if not found
// Single product, by URL slug (any locale — slugs are indexed across all languages) // Single product, by URL slug (any locale — slugs are indexed across all languages)
$product = $service->getBySlug('erotika-mprelok'); // array, or null if not found $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 // 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 // $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, // facets()'s docblock for why, and how to build a standard "every option's count,
@@ -91,13 +64,11 @@ $variants = $service->variantSummaries($product);
$brandCounts = $service->facets('brand', filters: new ProductFilters(collectionId: 17)); $brandCounts = $service->facets('brand', filters: new ProductFilters(collectionId: 17));
// ['3Dealer.gr - 3D printed creations' => 48, 'Kraniou Topos - 3D printed creations' => 135] // ['3Dealer.gr - 3D printed creations' => 48, 'Kraniou Topos - 3D printed creations' => 135]
// Min/max price across matching products — the raw, unrounded values list() itself // Min/max price across matching products, for sizing a price-range slider.
// uses to build priceBounds above. minPrice/maxPrice are ALWAYS excluded from the // minPrice/maxPrice are ALWAYS excluded from the filter driving this (unlike
// filter driving this (unlike facets(), which doesn't auto-exclude) — the slider's // facets(), which doesn't auto-exclude) — the slider's own bounds shouldn't shrink
// own bounds shouldn't shrink to whatever range is currently selected on it. Other // to whatever range is currently selected on it. Other filters (collectionId,
// filters (collectionId, brand, inStockOnly) still apply normally. Pass $query too // brand, inStockOnly) still apply normally.
// 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)); $range = $service->priceRange(new ProductFilters(collectionId: 17));
// ['min' => 0.0, 'max' => 120.0] // ['min' => 0.0, 'max' => 120.0]
``` ```
@@ -106,8 +77,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 `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 field like `price` would return one "facet" per exact price, not a usable range bucket. Use
`priceRange()` (or `list()`'s own `priceBounds`) for `price` instead, which reads Meilisearch's `priceRange()` for `price` instead, which reads Meilisearch's `facetStats` (min/max), a different
`facetStats` (min/max), a different feature from `facetDistribution`. feature from `facetDistribution`.
--- ---
+13 -39
View File
@@ -24,62 +24,36 @@ merges `$builder->options` directly into the search request).
## Usage ## Usage
```php ```php
use Modules\Core\Catalog\DTOs\ProductFilters;
use Modules\Core\Catalog\Enums\ProductSort;
use Modules\Core\Catalog\Services\ProductSearchService; use Modules\Core\Catalog\Services\ProductSearchService;
$results = app(ProductSearchService::class)->search('running shoes'); $results = app(ProductSearchService::class)->search('running shoes');
// or an explicit locale, bypassing App::getLocale():
// Filters/sort apply the exact same semantics ProductService::list() uses for $results = app(ProductSearchService::class)->search('running shoes', 'el');
// 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 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 `->get()` hydrates real models from the database after the Meilisearch query, so relations
(`variants`, `brand`, `media`, etc.) are available on the results as normal. (`variants`, `brand`, `media`, etc.) are available on the results as normal.
There is no `$locale` parameter — see "Field list is dynamic, not hardcoded" below for why `$locale` defaults to `App::getLocale()` — already set correctly on every storefront request by
every configured store language is always searched, regardless of the current request locale. `Modules\Core\Localization\Middleware\LocaleMiddleware` (see `localization.md`), so callers in controllers
don't need to pass it explicitly.
--- ---
## Missing-translation fallback, in both directions ## Missing-translation fallback
If a product was only ever given an English name, `name_el` doesn't exist on that document at 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 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 attribute's stored data — see `ScoutIndexer::mapSearchableAttributes()`). Searching strictly
against the current request's locale field would make that product invisible whenever a shopper's against `name_el` would make that product invisible to Greek-locale search, even though it's a
locale doesn't match the language it happens to be translated into. real catalog item.
`ProductSearchService` avoids this by targeting **every configured store language's fields** To avoid silently hiding incompletely-translated products, `ProductSearchService` targets **both**
(`Lunar\Models\Language::all()`) on every search, not just the current request locale plus the the resolved locale's fields **and** the default language's fields
store default — e.g. with `el`/`en` configured, every search targets `name_el`, `name_en`, (`Lunar\Models\Language::getDefault()->code`) — e.g. searching in `el` targets `name_el`,
`description_el`, `description_en` together, regardless of which locale the shopper is browsing `name_en`, `description_el`, `description_en` together (assuming `en` is the default language).
in. This is deliberately not scoped to "current locale + default locale": if the current locale A product missing an `el` translation still matches via its `en` fields.
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.
--- ---
-19
View File
@@ -1,19 +0,0 @@
<?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,
) {}
}
-23
View File
@@ -1,23 +0,0 @@
<?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,
) {}
}
+15 -47
View File
@@ -3,12 +3,10 @@
namespace Modules\Core\Catalog\Services; namespace Modules\Core\Catalog\Services;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\App;
use Lunar\Facades\AttributeManifest; use Lunar\Facades\AttributeManifest;
use Lunar\Models\Language; use Lunar\Models\Language;
use Lunar\Models\Product; 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 * Lunar's Meilisearch indexer flattens translated attributes into locale-suffixed
@@ -19,69 +17,39 @@ use Modules\Core\Catalog\Support\ProductFilterBuilder;
*/ */
class ProductSearchService 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> * @return Collection<int, Product>
*/ */
public function search(string $query, ?ProductFilters $filters = null, ?ProductSort $sort = null): Collection public function search(string $query, ?string $locale = null): Collection
{ {
$options = [ $locale ??= App::getLocale();
'attributesToSearchOn' => $this->searchableFields(), $defaultLocale = Language::getDefault()->code;
'filter' => $this->filterBuilder->build($filters),
];
if ($sort !== null) {
$options['sort'] = [$sort->toMeilisearchSort()];
}
return Product::search($query) return Product::search($query)
->options($options) ->options([
'attributesToSearchOn' => $this->searchableFields($locale, $defaultLocale),
])
->get(); ->get();
} }
/** /**
* Targets every configured store language's fields, not just the current * Target the resolved locale's fields plus the default locale's fields, so a
* request locale plus the store default — a shopper browsing in Greek * product that's only ever been translated into the default language still
* typing an English word (or vice versa) should still match a product * surfaces when searched in another locale, instead of becoming invisible
* whose only translation for that text happens to be in a third * until every product is fully translated.
* 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> * @return array<int, string>
*/ */
private function searchableFields(): array private function searchableFields(string $locale, string $defaultLocale): array
{ {
$handles = AttributeManifest::getSearchableAttributes(Product::morphName()) $handles = AttributeManifest::getSearchableAttributes(Product::morphName())
->pluck('handle'); ->pluck('handle');
$locales = Language::all()->pluck('code'); $locales = array_unique([$locale, $defaultLocale]);
$attributeFields = $handles return $handles
->crossJoin($locales) ->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() ->values()
->all(); ->all();
} }
+42 -144
View File
@@ -4,16 +4,14 @@ namespace Modules\Core\Catalog\Services;
use Illuminate\Contracts\Pagination\LengthAwarePaginator as LengthAwarePaginatorContract; use Illuminate\Contracts\Pagination\LengthAwarePaginator as LengthAwarePaginatorContract;
use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\App;
use Lunar\Base\AttributeManifest; use Lunar\Base\AttributeManifest;
use Lunar\FieldTypes\TranslatedText; use Lunar\FieldTypes\TranslatedText;
use Lunar\Models\Product; use Lunar\Models\Product;
use Modules\Core\Localization\Services\LanguageCache; use Modules\Core\Localization\Services\LanguageCache;
use Modules\Core\Catalog\DTOs\PriceSliderBounds;
use Modules\Core\Catalog\DTOs\ProductFilters; use Modules\Core\Catalog\DTOs\ProductFilters;
use Modules\Core\Catalog\DTOs\ProductListingResult;
use Modules\Core\Catalog\Enums\ProductSort; use Modules\Core\Catalog\Enums\ProductSort;
use Modules\Core\Catalog\Support\ProductFilterBuilder;
/** /**
* Storefront product listing/filtering AND single-product lookup, all reading directly * Storefront product listing/filtering AND single-product lookup, all reading directly
@@ -29,33 +27,17 @@ class ProductService
public function __construct( public function __construct(
private readonly LanguageCache $languages, private readonly LanguageCache $languages,
private readonly AttributeManifest $attributes, private readonly AttributeManifest $attributes,
private readonly ProductFilterBuilder $filterBuilder,
) {} ) {}
/** /**
* One call for everything a listing page needs: the product page AND * Returns a real LengthAwarePaginator (not Scout's own paginateRaw() result -
* the price slider's bounds — a controller used to have to call this * see "Meilisearch driver quirk" below) so a controller/view gets normal
* plus priceSliderBounds() separately and glue the results together * pagination behaviour ($products->links(), JSON serialization, etc.)
* itself; that orchestration now happens in here instead. Still issues * without ever touching the raw Meilisearch response directly.
* 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): ProductListingResult public function list(?ProductFilters $filters = null, int $perPage = 24, int $page = 1, ?ProductSort $sort = null): LengthAwarePaginator
{ {
$options = ['filter' => $this->filterBuilder->build($filters)]; $options = ['filter' => $this->buildFilter($filters)];
if ($sort !== null) { if ($sort !== null) {
$options['sort'] = [$sort->toMeilisearchSort()]; $options['sort'] = [$sort->toMeilisearchSort()];
@@ -69,17 +51,13 @@ class ProductService
->map(fn (array $product) => $this->withLocalizedFields($product)) ->map(fn (array $product) => $this->withLocalizedFields($product))
->all(); ->all();
$products = new LengthAwarePaginator( return new LengthAwarePaginator(
items: $data, items: $data,
total: $paginator->total(), total: $paginator->total(),
perPage: $paginator->perPage(), perPage: $paginator->perPage(),
currentPage: $paginator->currentPage(), currentPage: $paginator->currentPage(),
options: ['path' => LengthAwarePaginator::resolveCurrentPath()], options: ['path' => LengthAwarePaginator::resolveCurrentPath()],
); );
$priceBounds = $this->priceSliderBounds($filters, $filters?->minPrice, $filters?->maxPrice);
return new ProductListingResult($products, $priceBounds);
} }
/** /**
@@ -101,7 +79,7 @@ class ProductService
*/ */
public function facets(string $field, ?ProductFilters $filters = null): array public function facets(string $field, ?ProductFilters $filters = null): array
{ {
return $this->rawFacets($field, $this->filterBuilder->build($filters))['facetDistribution'][$field] ?? []; return $this->rawFacets($field, $this->buildFilter($filters))['facetDistribution'][$field] ?? [];
} }
/** /**
@@ -112,17 +90,12 @@ class ProductService
* not `facetDistribution` — the right feature for a numeric field's range, * not `facetDistribution` — the right feature for a numeric field's range,
* where `facets('price')` would otherwise return one entry per exact price. * 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 * @return array{min: ?float, max: ?float} null/null if no product matches
*/ */
public function priceRange(?ProductFilters $filters = null, string $query = ''): array public function priceRange(?ProductFilters $filters = null): array
{ {
$filter = $this->filterBuilder->build($filters, exclude: ['price']); $filter = $this->buildFilter($filters, exclude: ['price']);
$stats = $this->rawFacets('price', $filter, $query)['facetStats']['price'] ?? null; $stats = $this->rawFacets('price', $filter)['facetStats']['price'] ?? null;
return [ return [
'min' => $stats['min'] ?? null, 'min' => $stats['min'] ?? null,
@@ -130,36 +103,9 @@ 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($query) return Product::search('')
->options([ ->options([
'filter' => $filter, 'filter' => $filter,
'facets' => [$field], 'facets' => [$field],
@@ -187,87 +133,15 @@ class ProductService
return $this->findOneWhere("id = \"{$id}\""); 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 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('') $paginator = Product::search('')
->options(['filter' => $filter]) ->options(['filter' => $filter])
->paginateRaw(perPage: $limit, page: 1); ->paginateRaw(perPage: 1, page: 1);
return collect($this->hitsFrom($paginator)) $product = $this->hitsFrom($paginator)[0] ?? null;
->map(fn (array $product) => $this->withLocalizedFields($product))
->all(); return $product !== null ? $this->withLocalizedFields($product) : null;
} }
/** /**
@@ -330,4 +204,28 @@ class ProductService
return collect($rawResponse['hits'] ?? [])->values()->all(); 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 ');
}
} }
@@ -1,40 +0,0 @@
<?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 ');
}
}
+36
View File
@@ -0,0 +1,36 @@
<?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 = [],
) {}
}
+18 -23
View File
@@ -12,15 +12,16 @@ use Lunar\Facades\ShippingManifest;
use Lunar\Models\Cart; use Lunar\Models\Cart;
use Lunar\Models\Order; use Lunar\Models\Order;
use Modules\Core\Cart\Services\CartService; use Modules\Core\Cart\Services\CartService;
use Modules\Core\Checkout\Contracts\PaymentDriver;
use Modules\Core\Checkout\Events\BillingAddressSet; use Modules\Core\Checkout\Events\BillingAddressSet;
use Modules\Core\Checkout\Events\OrderPlaced; use Modules\Core\Checkout\Events\OrderPlaced;
use Modules\Core\Checkout\Events\PaymentConfirmed;
use Modules\Core\Checkout\Events\PaymentMethodSelected; use Modules\Core\Checkout\Events\PaymentMethodSelected;
use Modules\Core\Checkout\Events\ShippingAddressSet; use Modules\Core\Checkout\Events\ShippingAddressSet;
use Modules\Core\Checkout\Events\ShippingOptionSelected; use Modules\Core\Checkout\Events\ShippingOptionSelected;
use Modules\Core\Checkout\Exceptions\InvalidShippingOptionException; use Modules\Core\Checkout\Exceptions\InvalidShippingOptionException;
use Modules\Core\Checkout\Exceptions\UnknownPaymentTypeException; use Modules\Core\Checkout\Exceptions\UnknownPaymentTypeException;
use Modules\Core\Payment\Models\PaymentMethod; use Modules\Core\Payment\Models\PaymentMethod;
use Modules\Core\Payment\Services\PaymentDriverResolver;
/** /**
* Storefront-facing checkout operations, mirroring * Storefront-facing checkout operations, mirroring
@@ -41,6 +42,7 @@ class CheckoutService
{ {
public function __construct( public function __construct(
private readonly CartService $cart, private readonly CartService $cart,
private readonly PaymentDriverResolver $paymentDrivers,
) {} ) {}
public function setShippingAddress(array|Addressable $address): Cart public function setShippingAddress(array|Addressable $address): Cart
@@ -149,7 +151,7 @@ class CheckoutService
{ {
return PaymentMethod::where('enabled', true) return PaymentMethod::where('enabled', true)
->pluck('type') ->pluck('type')
->filter(fn (string $type) => $this->resolvePaymentDriver($type)?->isConfigured() ?? false) ->filter(fn (string $type) => $this->paymentDrivers->resolve($type)?->isConfigured() ?? false)
->values() ->values()
->all(); ->all();
} }
@@ -198,11 +200,18 @@ class CheckoutService
} }
/** /**
* Resolves $type's registered PaymentDriver and calls confirm() — * Resolves $type's registered PaymentDriver and calls confirm() — the
* the driver decides whether/when the order actually gets placed (see * driver independently decides whether payment succeeded and, if so,
* Modules\Core\Checkout\Contracts\PaymentDriver's docblock). $data * dispatches PaymentConfirmed (see PaymentDriver's docblock) rather
* carries whatever that driver needs (Stripe's payment_intent id, a * than placing the order itself or returning it here. This method is
* future redirect-based provider's callback payload). * 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).
* *
* The fingerprint passed to the driver is the one captured by * The fingerprint passed to the driver is the one captured by
* selectPaymentMethod(), not supplied by the caller — see that * selectPaymentMethod(), not supplied by the caller — see that
@@ -220,7 +229,7 @@ class CheckoutService
* @throws \Lunar\Exceptions\FingerprintMismatchException * @throws \Lunar\Exceptions\FingerprintMismatchException
* @throws \Lunar\Exceptions\Carts\CartException * @throws \Lunar\Exceptions\Carts\CartException
*/ */
public function confirmPayment(string $type, array $data = []): Order public function confirmPayment(string $type, array $data = []): void
{ {
if (! in_array($type, $this->getPaymentMethods(), true)) { if (! in_array($type, $this->getPaymentMethods(), true)) {
throw new UnknownPaymentTypeException($type); throw new UnknownPaymentTypeException($type);
@@ -229,20 +238,6 @@ class CheckoutService
$cart = $this->cart->currentOrCreate(); $cart = $this->cart->currentOrCreate();
$fingerprint = $cart->meta['checkout_fingerprint'] ?? ''; $fingerprint = $cart->meta['checkout_fingerprint'] ?? '';
return $this->resolvePaymentDriver($type)->confirm($cart, $type, $fingerprint, $data); $this->paymentDrivers->resolve($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;
} }
} }
-67
View File
@@ -1,67 +0,0 @@
<?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.');
}
}
@@ -0,0 +1,54 @@
<?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;
}
+65
View File
@@ -0,0 +1,65 @@
<?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;
}
@@ -0,0 +1,23 @@
<?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;
}
+24
View File
@@ -0,0 +1,24 @@
<?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;
}
@@ -0,0 +1,18 @@
<?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 = [],
) {}
}
@@ -0,0 +1,31 @@
<?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 = [],
) {}
}
@@ -0,0 +1,20 @@
<?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 = [],
) {}
}
+33 -29
View File
@@ -2,35 +2,28 @@
namespace Modules\Core\Payment\Drivers; namespace Modules\Core\Payment\Drivers;
use Lunar\Exceptions\Carts\CartException;
use Lunar\Exceptions\DisallowMultipleCartOrdersException;
use Lunar\Exceptions\FingerprintMismatchException;
use Lunar\Models\Cart; use Lunar\Models\Cart;
use Lunar\Models\Order; use Modules\Core\Checkout\Events\OrderPlaced;
use Modules\Core\Checkout\Contracts\PaymentDriver; use Modules\Core\Checkout\Events\PaymentConfirmed;
use Modules\Core\Checkout\Services\CheckoutService; use Modules\Core\Payment\Contracts\PaymentDriver;
/** /**
* Shared by every payment type with no real gateway to confirm against — * 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 * 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 places * delivery, not at checkout. confirm() has nothing to wait on, so it
* the order immediately, same as Lunar's own OfflinePayment would, but * dispatches PaymentConfirmed immediately, same moment Lunar's own
* through CheckoutService::placeOrder() so it goes through the same * OfflinePayment would place the order — but the actual placement now
* fingerprint check every other driver does. $data is unused: nothing about * happens in CheckoutService::onPaymentConfirmed(), not here. $data is
* this confirmation depends on gateway-specific payload. * unused: nothing about this confirmation depends on gateway-specific
* payload.
* *
* Sets the order status to config("lunar.payments.types.{$type}.authorized") * The status-mapping step this driver used to do inline right after
* afterward, using the type actually confirmed — not a hardcoded key — * placeOrder() returned now happens in onOrderPlaced() below instead —
* since this one driver is shared across multiple types. * see PaymentDriver's docblock for why a driver can no longer rely on
* placeOrder() itself leaves the order at Lunar's configured draft_status, * placeOrder()'s return value.
* same as every driver is responsible for moving it on from.
*/ */
class OfflinePaymentDriver implements PaymentDriver class OfflinePaymentDriver implements PaymentDriver
{ {
public function __construct(
private readonly CheckoutService $checkout,
) {}
/** /**
* Always true — no external dependency to be missing. * Always true — no external dependency to be missing.
*/ */
@@ -39,19 +32,30 @@ class OfflinePaymentDriver implements PaymentDriver
return true; return true;
} }
/** public function confirm(Cart $cart, string $type, string $fingerprint, array $data): void
* @throws FingerprintMismatchException
* @throws CartException
* @throws DisallowMultipleCartOrdersException
*/
public function confirm(Cart $cart, string $type, string $fingerprint, array $data): Order
{ {
$order = $this->checkout->placeOrder($fingerprint); 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.
*/
public function onOrderPlaced(OrderPlaced $event): void
{
$order = $event->order;
$type = $order->meta['payment_method'] ?? null;
if (! $type || config("lunar.payments.types.{$type}.payment_driver") !== self::class) {
return;
}
$order->update([ $order->update([
'status' => config("lunar.payments.types.{$type}.authorized", $order->status), 'status' => config("lunar.payments.types.{$type}.authorized", $order->status),
]); ]);
return $order->refresh();
} }
} }
+44 -30
View File
@@ -2,39 +2,40 @@
namespace Modules\Core\Payment\Drivers; 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\Cart;
use Lunar\Models\Order;
use Lunar\Stripe\Actions\UpdateOrderFromIntent; use Lunar\Stripe\Actions\UpdateOrderFromIntent;
use Lunar\Stripe\Facades\Stripe; use Lunar\Stripe\Facades\Stripe;
use Lunar\Stripe\Models\StripePaymentIntent; use Lunar\Stripe\Models\StripePaymentIntent;
use Modules\Core\Checkout\Contracts\PaymentDriver; use Modules\Core\Checkout\Events\OrderPlaced;
use Modules\Core\Checkout\Services\CheckoutService; use Modules\Core\Checkout\Events\PaymentConfirmed;
use Modules\Core\Payment\Contracts\PaymentDriver;
use Modules\Core\Payment\Exceptions\PaymentNotConfirmedException; use Modules\Core\Payment\Exceptions\PaymentNotConfirmedException;
use Stripe\PaymentIntent; use Stripe\PaymentIntent;
/** /**
* Wraps Lunar\Stripe\StripePaymentType::authorize() to satisfy * Wraps Lunar\Stripe\StripePaymentType::authorize() to satisfy
* Modules\Core\Checkout\Contracts\PaymentDriver — calls * Modules\Core\Payment\Contracts\PaymentDriver — dispatches
* CheckoutService::placeOrder($fingerprint) at the moment Stripe confirms * PaymentConfirmed at the moment Stripe confirms payment, instead of the
* payment, instead of the vendor's own Cart::createOrder() call. * vendor's own Cart::createOrder() call.
* *
* This is a fork, not a decoration: StripePaymentType::authorize() is * This is a fork, not a decoration: StripePaymentType::authorize() is
* `final` and calls Cart::createOrder() directly with no seam to redirect * `final` and calls Cart::createOrder() directly with no seam to redirect
* that one call — so this class reimplements authorize()'s logic (intent * that one call — so this class reimplements authorize()'s logic (intent
* retrieval, capture-on-policy, status mapping via UpdateOrderFromIntent) * retrieval, capture-on-policy) rather than wrapping the vendor method.
* rather than wrapping the vendor method. Kept deliberately close to the * Kept deliberately close to the original so a lunarphp/stripe upgrade is
* original so a lunarphp/stripe upgrade is easy to diff against. See * easy to diff against. See docs/payments.md.
* 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.
*/ */
class StripePaymentDriver implements PaymentDriver class StripePaymentDriver implements PaymentDriver
{ {
public function __construct(
private readonly CheckoutService $checkout,
) {}
/** /**
* Same key lunarphp/stripe's own StripeManager reads its API key from * Same key lunarphp/stripe's own StripeManager reads its API key from
* (Stripe::setApiKey(config('services.stripe.key')) in * (Stripe::setApiKey(config('services.stripe.key')) in
@@ -47,13 +48,11 @@ class StripePaymentDriver implements PaymentDriver
/** /**
* @throws PaymentNotConfirmedException if Stripe hasn't confirmed the * @throws PaymentNotConfirmedException if Stripe hasn't confirmed the
* payment intent (wrong intent id, already processed, order already * payment intent (wrong intent id, already processed, or the gateway
* placed, or the gateway call itself fails) — nothing here should be * call itself fails) — nothing here should be treated as "confirm
* treated as "place the order anyway." * anyway."
* @throws FingerprintMismatchException
* @throws CartException
*/ */
public function confirm(Cart $cart, string $type, string $fingerprint, array $data): Order public function confirm(Cart $cart, string $type, string $fingerprint, array $data): void
{ {
$paymentIntentId = $data['payment_intent']; $paymentIntentId = $data['payment_intent'];
@@ -93,19 +92,34 @@ class StripePaymentDriver implements PaymentDriver
); );
} }
try { $paymentIntentModel->status = $paymentIntent->status;
$order = $this->checkout->placeOrder($fingerprint); $paymentIntentModel->save();
} catch (DisallowMultipleCartOrdersException|CartException $e) {
throw new PaymentNotConfirmedException($e->getMessage(), previous: $e); 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;
} }
$paymentIntentModel->order_id = $order->id; $paymentIntentModel->order_id = $order->id;
$paymentIntentModel->status = $paymentIntent->status;
$paymentIntentModel->processed_at = now(); $paymentIntentModel->processed_at = now();
$paymentIntentModel->save(); $paymentIntentModel->save();
UpdateOrderFromIntent::execute($order, $paymentIntent); $paymentIntent = Stripe::getClient()->paymentIntents->retrieve($paymentIntentModel->intent_id);
return $order->refresh(); UpdateOrderFromIntent::execute($order, $paymentIntent);
} }
} }
@@ -0,0 +1,32 @@
<?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';
}
+39
View File
@@ -0,0 +1,39 @@
<?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; use Throwable;
/** /**
* Thrown by a Modules\Core\Checkout\Contracts\PaymentDriver when the * Thrown by a Modules\Core\Payment\Contracts\PaymentDriver when the
* gateway has not confirmed payment — wrong/expired intent, already * gateway has not confirmed payment — wrong/expired intent, already
* processed, or the gateway itself rejects the confirmation. A driver * processed, or the gateway itself rejects the confirmation. A driver
* throws this instead of silently placing the order: CheckoutService:: * throws this instead of silently placing the order: CheckoutService::
@@ -0,0 +1,36 @@
<?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),
]);
}
}
@@ -0,0 +1,29 @@
<?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;
}
}
@@ -0,0 +1,49 @@
<?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,
]);
}
}
+1 -2
View File
@@ -10,7 +10,6 @@ use Modules\Core\Command\ExportCommand;
use Modules\Core\Command\ImportCommand; use Modules\Core\Command\ImportCommand;
use Modules\Core\Command\InstallLunarCommand; use Modules\Core\Command\InstallLunarCommand;
use Modules\Core\Command\MigrateImportCommand; use Modules\Core\Command\MigrateImportCommand;
use Modules\Core\Command\TuneProductSearchCommand;
class CoreServiceProvider extends ServiceProvider class CoreServiceProvider extends ServiceProvider
{ {
@@ -36,7 +35,7 @@ class CoreServiceProvider extends ServiceProvider
], 'core-assets'); ], 'core-assets');
if ($this->app->runningInConsole()) { if ($this->app->runningInConsole()) {
$this->commands([AnonymizeCommand::class, ExportCommand::class, ExportCleanupCommand::class, ImportCommand::class, MigrateImportCommand::class, TuneProductSearchCommand::class]); $this->commands([AnonymizeCommand::class, ExportCommand::class, ExportCleanupCommand::class, ImportCommand::class, MigrateImportCommand::class]);
//Overriding lunar:install //Overriding lunar:install
$this->app->booted(fn () => $this->commands([InstallLunarCommand::class])); $this->app->booted(fn () => $this->commands([InstallLunarCommand::class]));