Compare commits

...
7 Commits
42 changed files with 909 additions and 121 deletions
+12
View File
@@ -4,6 +4,18 @@ 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.1] - 2026-09-03
### Fixed
- `Modules\Core\MigrateImport\Shopify\ShopifyExportImporter` now attaches a variant's `Variant Image` CSV column to that `ProductVariant`'s own `images()` media pivot (`media_product_variant`, `primary`/`position`). Previously the variant image was never read at all — every image from the CSV, including ones the export clearly scopes to one specific variant, went only into the product's own top-level gallery, so a variant swatch/option change had no way to show its own photo.
- `Modules\Core\MigrateImport\Shopify\Resolvers\ProductOptionResolver::resolveOption()` now sets `label` (same value as `name`) when creating a `Lunar\Models\ProductOption`, not just `name`. A `ProductOption` with a null `label` crashes Lunar's own `ProductOptionIndexer::toSearchableArray()` (`foreach()` on `null`) the moment that option gets reindexed — every option created by the importer before this fix has a null `label` and needs a wipe-and-reimport (see `docs/shopify-reimport.md`, new in this release) to pick up the fix, since `firstOrCreate()` never revisits an already-existing row.
- `product_reviews.product_id`'s foreign key had no `ON DELETE` clause, so deleting a reviewed `Product` threw a constraint violation instead of the review going with it, unlike every other product-dependent table. New migration adds `cascadeOnDelete()`.
### Added
- `Modules\Core\Catalog\Services\ProductIndexer::mapVariant()` now embeds `gtin`, `mpn`, `ean`, `backorder`, `unit_quantity`, `shippable`, `tax_ref`, and `dimensions` (length/width/height/weight/volume, each with `value`+`unit`) on every indexed variant — previously only `id`/`sku`/`stock`/`purchasable`/`options`/`prices`/`media` were embedded, so a search result or filter needing any of these had no way to get at them without a separate Postgres query per variant.
- `ProductIndexer::toSearchableArray()` adds a top-level, filterable `skus` field (every variant's SKU, deduplicated) — filtering/matching by SKU no longer requires reaching into the nested `variants` array.
- `docs/shopify-reimport.md` — runbook for wiping every imported product (cascading through Lunar so Meilisearch documents go too) and re-running the importer from scratch, needed whenever a fix like the two above only takes effect on newly-created rows.
## [0.12.0] - 2026-09-03 ## [0.12.0] - 2026-09-03
### Changed ### Changed
+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.12.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,
@@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* product_reviews.product_id's original foreign key (2026_07_10_000001) had
* no ON DELETE clause, so deleting a Product with reviews throws a
* constraint violation instead of the review rows going with it — unlike
* every other Product-dependent table (variants, media, etc.), which does
* cascade. A review is dependent, disposable data, not something worth
* blocking a product deletion over.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('product_reviews', function (Blueprint $table) {
$table->dropForeign(['product_id']);
});
Schema::table('product_reviews', function (Blueprint $table) {
$table->foreign('product_id')
->references('id')
->on(config('lunar.database.table_prefix').'products')
->cascadeOnDelete();
});
}
public function down(): void
{
Schema::table('product_reviews', function (Blueprint $table) {
$table->dropForeign(['product_id']);
});
Schema::table('product_reviews', function (Blueprint $table) {
$table->foreign('product_id')
->references('id')
->on(config('lunar.database.table_prefix').'products');
});
}
};
+2 -1
View File
@@ -135,11 +135,12 @@ needs, listing and detail alike:
| `collections` | `$product->collections` | Array of `{id, name}` — directly assigned collections only, `name` is the translated collection name. Not filterable — see `collection_ids`. | | `collections` | `$product->collections` | Array of `{id, name}` — directly assigned collections only, `name` is the translated collection name. Not filterable — see `collection_ids`. |
| `collection_ids` | `$product->collections` + `->ancestors` | Filterable. Flat array of every directly-assigned collection's id, unioned with all of its ancestors' ids. `ProductFilters(collectionId: ...)` filters against this field, not `collections`, since products are typically attached only to leaf collections — a plain direct-match filter would never return anything for a parent/root category page. | | `collection_ids` | `$product->collections` + `->ancestors` | Filterable. Flat array of every directly-assigned collection's id, unioned with all of its ancestors' ids. `ProductFilters(collectionId: ...)` filters against this field, not `collections`, since products are typically attached only to leaf collections — a plain direct-match filter would never return anything for a parent/root category page. |
| `slugs` | `$product->urls->pluck('slug')` | Filterable. Every locale's `Url::slug` for the product, so `getBySlug()` resolves purely from the index — no database read. | | `slugs` | `$product->urls->pluck('slug')` | Filterable. Every locale's `Url::slug` for the product, so `getBySlug()` resolves purely from the index — no database read. |
| `skus` | `$product->variants->pluck('sku')` | Filterable. Every variant's `sku`, deduplicated, empty ones dropped. Same "resolve from the index alone" reasoning as `slugs`, for a future SKU-based lookup/filter. |
| `price` | Cheapest variant's base price | Filterable. Float in major units (e.g. `19.99`, not `1999`). Base price only — no customer group, default currency (`Currency::getDefault()`) only. `null` if the product has no priced variant yet, so it's excluded from range filters rather than treated as free. | | `price` | Cheapest variant's base price | Filterable. Float in major units (e.g. `19.99`, not `1999`). Base price only — no customer group, default currency (`Currency::getDefault()`) only. `null` if the product has no priced variant yet, so it's excluded from range filters rather than treated as free. |
| `brand` | Already indexed by Lunar's base indexer | Newly marked **filterable** — it existed in the document already, just wasn't usable in a `filter` clause. | | `brand` | Already indexed by Lunar's base indexer | Newly marked **filterable** — it existed in the document already, just wasn't usable in a `filter` clause. |
| `tags` | `$product->tags->pluck('value')` | Display only. | | `tags` | `$product->tags->pluck('value')` | Display only. |
| `media` | `$product->media` | Full gallery (id/url/thumb per image), not just the single thumbnail Lunar's base indexer sends. | | `media` | `$product->media` | Full gallery (id/url/thumb per image), not just the single thumbnail Lunar's base indexer sends. |
| `variants` | `$product->variants` | Per variant: `id`, `sku`, `stock`, `purchasable`, `options` (option/value names, in the current locale), `prices` (per currency/customer group), `media` (variant-specific images). | | `variants` | `$product->variants` | Per variant: `id`, `sku`, `gtin`, `mpn`, `ean`, `stock`, `backorder`, `unit_quantity`, `purchasable`, `shippable`, `tax_ref`, `dimensions` (`length`/`width`/`height`/`weight`/`volume`, each `{value, unit}`), `options` (option/value names, in the current locale), `prices` (per currency/customer group), `media` (the variant's own images — `ProductVariant::images()`, a separate pivot from the product's own gallery above, populated by `ShopifyExportImporter` from Shopify's `Variant Image` CSV column). |
| `reviews` | `Modules\Core\Review\Models\ProductReview` | `{items, count, average_rating}` — see "Reviews" below. | | `reviews` | `Modules\Core\Review\Models\ProductReview` | `{items, count, average_rating}` — see "Reviews" below. |
| `in_stock` | `$model->variants` | Filterable boolean. `true` if ANY variant currently passes `ProductVariant::canBeFulfilledAtQuantity(1)` — Lunar's own purchasability rule (`purchasable === 'always'` ignores stock entirely; `in_stock` checks `stock` alone; anything else checks `stock + backorder`). Only as fresh as the last reindex — see "Stock goes stale" below. | | `in_stock` | `$model->variants` | Filterable boolean. `true` if ANY variant currently passes `ProductVariant::canBeFulfilledAtQuantity(1)` — Lunar's own purchasability rule (`purchasable === 'always'` ignores stock entirely; `in_stock` checks `stock` alone; anything else checks `stock + backorder`). Only as fresh as the last reindex — see "Stock goes stale" below. |
+3
View File
@@ -2,6 +2,9 @@
Findings from comparing a real Shopify product export CSV against Lunar's schema (`vendor/lunarphp/core`), plus the resulting implementation plan for `MigrateImport\Shopify\ShopifyExportImporter`. Findings from comparing a real Shopify product export CSV against Lunar's schema (`vendor/lunarphp/core`), plus the resulting implementation plan for `MigrateImport\Shopify\ShopifyExportImporter`.
Need to discard everything and re-import from scratch (e.g. after a schema/indexer change that
only applies to newly-created rows)? See `docs/shopify-reimport.md`.
## Idempotency problem ## Idempotency problem
Nothing in Lunar tracks "this record came from external system X, ID Y." Re-running an import with no external-ID tracking would duplicate every product on each run. Nothing in Lunar tracks "this record came from external system X, ID Y." Re-running an import with no external-ID tracking would duplicate every product on each run.
+149
View File
@@ -0,0 +1,149 @@
# Wiping products before a clean Shopify re-import
A runbook for discarding every imported product (and everything that hangs off one —
variants, prices, media, reviews, options/values, the Meilisearch documents) and re-running
`ShopifyExportImporter` from scratch. Useful after a schema/indexer change that only applies to
newly-created rows (see "Why a wipe, not an update" below), or when the export CSV itself changed
enough that stale products need to go, not just be updated in place.
Every command below is a `tinker --execute=` one-liner run inside the app container — adjust the
exec prefix (`./bin/dc-core.sh exec app ...`, `docker compose exec app ...`, etc.) for your setup.
---
## Why a wipe, not an update
`ShopifyExportImporter`'s resolvers are mostly `firstOrCreate` — re-running the importer against
an *existing* database updates matched rows but leaves already-created ones exactly as they were.
That's the right behavior for routine re-imports (an updated price, a new variant), but it means a
change to what gets set **at creation time only** — e.g. `ProductOptionResolver` now also setting
`label`, not just `name`, on a `ProductOption` — never reaches a `ProductOption` row that already
exists. A wipe forces every row to go through creation again, picking up such fixes.
---
## 1. Delete every product
Cascades to `ProductVariant`, prices, and Spatie media rows — verified live (see
`shopify-import.md`'s own history/commit log for context). Also removes each product's Meilisearch
document automatically, via Scout's own delete hook fired on `forceDelete()` — no separate
`scout:flush` needed.
```php
\Lunar\Models\Product::withTrashed()->get()->each->forceDelete();
```
**Let this run to completion.** Interrupting it mid-loop (e.g. Ctrl+C on the tinker session) stops
after whichever product it was on, leaving the rest undeleted — safe to just re-run the same
command again afterward, since already-deleted products are simply skipped.
Verify:
```php
\Lunar\Models\Product::withTrashed()->count(); // 0
```
### Requires: `product_reviews.product_id` cascades on delete
`product_reviews` (boboko-core's own table, not Lunar's) originally had no `ON DELETE` clause on
its `product_id` foreign key — deleting a reviewed product threw a constraint violation instead of
the review going with it. Fixed by
`database/migrations/2026_09_03_000001_add_cascade_delete_to_product_reviews_product_id.php`. Make
sure this migration has actually run (`php artisan migrate`) before step 1, or a product with
reviews will fail to delete.
---
## 2. Delete product options and values
Not touched by step 1 (`ProductOption`/`ProductOptionValue` aren't scoped to one product — they're
shared across the catalog, per `ProductOptionResolver::resolveOption()`'s `shared: true`). Safe to
delete in full once every product (and therefore every variant referencing an option value via the
`product_option_value_product_variant` pivot) is gone — deleting values while variants still
reference them throws the same kind of FK violation step 1 guards against.
```php
\Lunar\Models\ProductOptionValue::query()->delete();
\Lunar\Models\ProductOption::query()->delete();
```
Verify:
```php
\Lunar\Models\ProductOption::count(); // 0
\Lunar\Models\ProductOptionValue::count(); // 0
```
---
## 3. Clear the import mappings
Without this, the importer's `ImportMapping::resolve(...)` calls still find the (now-deleted)
mappings' rows absent, so this step is really about not leaving stale mapping rows pointing at
nothing — `ImportMapping` rows aren't foreign-keyed to the models they map (`morphTo`, no
constraint), so leaving them wouldn't break the re-import, but a stale mapping for a product that
no longer exists is dead weight.
```php
\Modules\Core\MigrateImport\Models\ImportMapping::where('source', 'shopify')->delete();
```
Verify:
```php
\Modules\Core\MigrateImport\Models\ImportMapping::where('source', 'shopify')->count(); // 0
```
---
## 4. Re-run the importer
`boboko:migrate:import` dispatches `RunMigrateImportJob` onto the queue — **not synchronous** —
so a queue worker must actually be running (`php artisan queue:work`, or your dev queue container)
or the job just sits queued.
```bash
php artisan boboko:migrate:import --source=shopify --type=export --file=<absolute path to the CSV>
```
The `--file` value must be an **absolute path** inside the container (e.g.
`/var/www/html/storage/app/private/imports/shopify/products_export.csv`) when running
non-interactively — a path relative to `storage/app/private/imports` only resolves correctly when
the command can fall back to its interactive prompt, which isn't available in a scripted/non-TTY
run.
Watch the queue worker's own log output for `FAIL` entries (see `docs/lunar.md` or your compose
setup for how logs are routed to `docker compose logs`) — a clean run shows every
`Laravel\Scout\Jobs\MakeSearchable` / `Spatie\MediaLibrary\Conversions\Jobs\PerformConversionsJob`
line ending `DONE`, never `FAIL`.
---
## 5. Re-sync Meilisearch and reindex
```bash
php artisan lunar:meilisearch:setup
php artisan lunar:meilisearch:tune-product-search
php artisan lunar:search:index "Lunar\Models\Product" --refresh
```
`--refresh` re-syncs filterable/sortable index settings *and* reindexes every document — it does
not reset `typoTolerance`/`prefixSearch` (confirmed live: both survived a `--refresh` run
unchanged), so `tune-product-search` only needs re-running here for completeness/if it hadn't
already been applied, not because `--refresh` would have clobbered it.
---
## Verifying the result
```php
// Product count should match the CSV's actual unique `Handle` count, not
// whatever the database held before the wipe — those aren't the same number
// if stale/manually-added products existed alongside the CSV-sourced ones.
\Lunar\Models\Product::count();
// Spot-check that at least one variant picked up its own image (see
// shopify-import.md's "Images" section) — 0 is only correct if the CSV
// genuinely has no `Variant Image` values populated.
\Lunar\Models\ProductVariant::has('images')->count();
```
+2
View File
@@ -17,10 +17,12 @@ class ProductFilters
* not a direct-assignment-only match) — the right semantics for "products on * not a direct-assignment-only match) — the right semantics for "products on
* this category page", since products are typically attached only to leaf * this category page", since products are typically attached only to leaf
* collections. * collections.
* @param $tag exactly one tag — no multi-select yet.
*/ */
public function __construct( public function __construct(
public readonly ?int $collectionId = null, public readonly ?int $collectionId = null,
public readonly ?string $brand = null, public readonly ?string $brand = null,
public readonly ?string $tag = null,
public readonly ?float $minPrice = null, public readonly ?float $minPrice = null,
public readonly ?float $maxPrice = null, public readonly ?float $maxPrice = null,
public readonly bool $inStockOnly = false, public readonly bool $inStockOnly = false,
+17 -7
View File
@@ -6,18 +6,28 @@ use Illuminate\Pagination\LengthAwarePaginator;
/** /**
* Everything a listing page needs from one ProductService::list() call — * Everything a listing page needs from one ProductService::list() call —
* the product page itself plus the price slider's bounds, so a controller * the product page itself, the price slider's bounds, and the set of tags
* makes one service call instead of orchestrating list() and * actually present on matching products (for a tag filter sidebar) — so a
* priceSliderBounds() separately. list() still issues two Meilisearch * controller makes one service call instead of orchestrating list(),
* requests internally (the product search and the price facet stats — see * priceSliderBounds(), and facets('tags', ...) separately. list() still
* priceSliderBounds()'s own docblock for why they can't be merged into one * issues multiple Meilisearch requests internally (the product search, the
* without changing the slider's UX), but that's this DTO's job to hide, * price facet stats, the tag facet distribution — see priceSliderBounds()'s
* not the controller's to know about. * own docblock for why the price ones 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 class ProductListingResult
{ {
/**
* @param array<int, string> $availableTags every distinct tag value
* present on at least one product matching the listing's OTHER
* filters (collection/price/stock — never the tag filter itself, so
* selecting a tag doesn't collapse the list down to just that tag).
* Sorted alphabetically. Empty if no product in scope has any tag.
*/
public function __construct( public function __construct(
public readonly LengthAwarePaginator $products, public readonly LengthAwarePaginator $products,
public readonly PriceSliderBounds $priceBounds, public readonly PriceSliderBounds $priceBounds,
public readonly array $availableTags = [],
) {} ) {}
} }
+24 -1
View File
@@ -27,8 +27,14 @@ use Spatie\MediaLibrary\MediaCollections\Models\Media;
* - slugs (every locale's Url::slug for the product, filterable) — lets * - slugs (every locale's Url::slug for the product, filterable) — lets
* ProductService::getBySlug() resolve a product from the index directly, with * ProductService::getBySlug() resolve a product from the index directly, with
* no database read at all * no database read at all
* - skus (every variant's sku, deduplicated, filterable) — same "resolve from the
* index alone" reasoning as slugs, for a future SKU-based lookup/filter
* - price (cheapest variant, filterable) and full per-variant pricing * - price (cheapest variant, filterable) and full per-variant pricing
* - variants: sku, stock, purchasable, option values, prices, media * - variants: sku, gtin, mpn, ean, stock, backorder, unit_quantity, purchasable,
* shippable, tax_ref, dimensions (length/width/height/weight/volume, each
* {value, unit}), option values, prices, media — the variant's own images
* (ProductVariant::images(), separate from the product's gallery below), not
* the product's own media repeated per variant
* - the full media gallery (not just the single thumbnail Lunar's base indexer sends) * - the full media gallery (not just the single thumbnail Lunar's base indexer sends)
* - tags * - tags
* - reviews: {items: [...], count, average_rating} — items are public-safe fields * - reviews: {items: [...], count, average_rating} — items are public-safe fields
@@ -83,6 +89,8 @@ class ProductIndexer extends BaseProductIndexer
'channel_ids', 'channel_ids',
'in_stock', 'in_stock',
'recommendations.id', 'recommendations.id',
'skus',
'tags',
]; ];
} }
@@ -126,6 +134,7 @@ class ProductIndexer extends BaseProductIndexer
->values() ->values()
->all(); ->all();
$data['slugs'] = $model->urls->pluck('slug')->unique()->values()->all(); $data['slugs'] = $model->urls->pluck('slug')->unique()->values()->all();
$data['skus'] = $model->variants->pluck('sku')->filter()->unique()->values()->all();
$data['tags'] = $model->tags->pluck('value')->all(); $data['tags'] = $model->tags->pluck('value')->all();
$data['media'] = $model->media->map(fn (Media $media) => $this->mapMedia($media))->all(); $data['media'] = $model->media->map(fn (Media $media) => $this->mapMedia($media))->all();
$data['variants'] = $model->variants->map(fn (ProductVariant $variant) => $this->mapVariant($variant, $currency))->all(); $data['variants'] = $model->variants->map(fn (ProductVariant $variant) => $this->mapVariant($variant, $currency))->all();
@@ -161,8 +170,22 @@ class ProductIndexer extends BaseProductIndexer
return [ return [
'id' => $variant->id, 'id' => $variant->id,
'sku' => $variant->sku, 'sku' => $variant->sku,
'gtin' => $variant->gtin,
'mpn' => $variant->mpn,
'ean' => $variant->ean,
'stock' => $variant->stock, 'stock' => $variant->stock,
'backorder' => $variant->backorder,
'unit_quantity' => $variant->unit_quantity,
'purchasable' => $variant->purchasable, 'purchasable' => $variant->purchasable,
'shippable' => $variant->shippable,
'tax_ref' => $variant->tax_ref,
'dimensions' => [
'length' => ['value' => $variant->length_value, 'unit' => $variant->length_unit],
'width' => ['value' => $variant->width_value, 'unit' => $variant->width_unit],
'height' => ['value' => $variant->height_value, 'unit' => $variant->height_unit],
'weight' => ['value' => $variant->weight_value, 'unit' => $variant->weight_unit],
'volume' => ['value' => $variant->volume_value, 'unit' => $variant->volume_unit],
],
'options' => $variant->values->map(fn ($value) => [ 'options' => $variant->values->map(fn ($value) => [
'option' => $this->translatedName($value->option->name), 'option' => $this->translatedName($value->option->name),
'handle' => $value->option->handle, 'handle' => $value->option->handle,
+26 -4
View File
@@ -78,8 +78,27 @@ class ProductService
); );
$priceBounds = $this->priceSliderBounds($filters, $filters?->minPrice, $filters?->maxPrice); $priceBounds = $this->priceSliderBounds($filters, $filters?->minPrice, $filters?->maxPrice);
$availableTags = $this->availableTags($filters);
return new ProductListingResult($products, $priceBounds); return new ProductListingResult($products, $priceBounds, $availableTags);
}
/**
* Every distinct `tags` value present on a product matching $filters,
* excluding $filters->tag itself — same "scoped but not self-collapsing"
* reasoning as priceRange() excluding `price` — so selecting a tag
* doesn't shrink the sidebar down to just that one tag. Sorted
* alphabetically; Meilisearch's facetDistribution has no defined order
* of its own.
*
* @return array<int, string>
*/
private function availableTags(?ProductFilters $filters): array
{
$filter = $this->filterBuilder->build($filters, exclude: ['tag']);
$tags = $this->rawFacets('tags', $filter)['facetDistribution']['tags'] ?? [];
return collect($tags)->keys()->sort()->values()->all();
} }
/** /**
@@ -93,9 +112,12 @@ class ProductService
* apply that field's own filter separately in the UI/query layer. * apply that field's own filter separately in the UI/query layer.
* *
* `$field` must be one of ProductIndexer's filterable fields; only discrete-value * `$field` must be one of ProductIndexer's filterable fields; only discrete-value
* fields make sense here (`brand`, `in_stock`) — a numeric field like `price` * fields make sense here (`brand`, `tags`, `in_stock`) — a numeric field like
* would return one "facet" per exact price, not a usable range bucket. Use * `price` would return one "facet" per exact price, not a usable range bucket.
* `priceRange()` for `price` instead. * Use `priceRange()` for `price` instead. `facets('tags', $filters)` is how a
* category page gets "which tags actually appear on products in this category" —
* pass a $filters that omits `tag` (see `build()`'s $exclude) so the tag list
* itself doesn't collapse to whichever tag is already selected.
* *
* @return array<string, int> facet value => matching product count * @return array<string, int> facet value => matching product count
*/ */
+2 -1
View File
@@ -14,7 +14,7 @@ use Modules\Core\Catalog\DTOs\ProductFilters;
class ProductFilterBuilder class ProductFilterBuilder
{ {
/** /**
* @param array<int, 'collectionId'|'brand'|'price'|'inStockOnly'> $exclude * @param array<int, 'collectionId'|'brand'|'tag'|'price'|'inStockOnly'> $exclude
* filter fields to leave out even if set on $filters — e.g. * filter fields to leave out even if set on $filters — e.g.
* ProductService::priceRange() excludes 'price' so a price slider's own * ProductService::priceRange() excludes 'price' so a price slider's own
* bounds don't shrink to whatever range is already selected on it. * bounds don't shrink to whatever range is already selected on it.
@@ -28,6 +28,7 @@ class ProductFilterBuilder
$clauses = Collection::make([ $clauses = Collection::make([
'collectionId' => $filters->collectionId !== null ? "collection_ids = \"{$filters->collectionId}\"" : null, 'collectionId' => $filters->collectionId !== null ? "collection_ids = \"{$filters->collectionId}\"" : null,
'brand' => $filters->brand !== null ? 'brand = "'.addcslashes($filters->brand, '"\\').'"' : null, 'brand' => $filters->brand !== null ? 'brand = "'.addcslashes($filters->brand, '"\\').'"' : null,
'tag' => $filters->tag !== null ? 'tags = "'.addcslashes($filters->tag, '"\\').'"' : null,
'price' => Collection::make([ 'price' => Collection::make([
$filters->minPrice !== null ? "price >= {$filters->minPrice}" : null, $filters->minPrice !== null ? "price >= {$filters->minPrice}" : null,
$filters->maxPrice !== null ? "price <= {$filters->maxPrice}" : null, $filters->maxPrice !== null ? "price <= {$filters->maxPrice}" : null,
+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;
} }
} }
@@ -16,10 +16,16 @@ class ProductOptionResolver
// the same option instead of creating a near-duplicate. // the same option instead of creating a near-duplicate.
$handle = Str::slug($name) ?: 'option'; $handle = Str::slug($name) ?: 'option';
// 'label' must be set even though nothing here reads it back — a null
// label crashes Lunar's own ProductOptionIndexer::toSearchableArray()
// (foreach (null as ...)) the moment this option gets reindexed, since
// it assumes every ProductOption always has one. Same value as 'name'
// is a reasonable default; Shopify's CSV has no separate "label" concept.
return ProductOption::query()->firstOrCreate( return ProductOption::query()->firstOrCreate(
['handle' => $handle], ['handle' => $handle],
[ [
'name' => [DefaultLocale::code() => $name], 'name' => [DefaultLocale::code() => $name],
'label' => [DefaultLocale::code() => $name],
'shared' => true, 'shared' => true,
], ],
); );
@@ -25,6 +25,7 @@ use Modules\Core\MigrateImport\Shopify\Resolvers\ProductOptionResolver;
use Modules\Core\MigrateImport\Shopify\Resolvers\ProductTypeResolver; use Modules\Core\MigrateImport\Shopify\Resolvers\ProductTypeResolver;
use Modules\Core\MigrateImport\Shopify\Resolvers\TagResolver; use Modules\Core\MigrateImport\Shopify\Resolvers\TagResolver;
use Modules\Core\MigrateImport\Shopify\Resolvers\TaxClassResolver; use Modules\Core\MigrateImport\Shopify\Resolvers\TaxClassResolver;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
class ShopifyExportImporter implements Importer class ShopifyExportImporter implements Importer
{ {
@@ -113,7 +114,7 @@ class ShopifyExportImporter implements Importer
$options = $this->attachOptions($product, $row); $options = $this->attachOptions($product, $row);
foreach ($group->variantRows as $index => $variantRow) { foreach ($group->variantRows as $index => $variantRow) {
$this->importVariant($product, $group->handle, $index, $variantRow, $taxClass, $currency, $options); $this->importVariant($product, $group->handle, $index, $variantRow, $taxClass, $currency, $options, $imagesPath);
} }
foreach ($group->imageRows as $index => $imageRow) { foreach ($group->imageRows as $index => $imageRow) {
@@ -151,6 +152,7 @@ class ShopifyExportImporter implements Importer
TaxClass $taxClass, TaxClass $taxClass,
Currency $currency, Currency $currency,
array $options, array $options,
string $imagesPath,
): void { ): void {
$externalId = "{$handle}#{$index}"; $externalId = "{$handle}#{$index}";
$existing = ImportMapping::resolve(self::SOURCE, 'variant', $externalId); $existing = ImportMapping::resolve(self::SOURCE, 'variant', $externalId);
@@ -182,6 +184,26 @@ class ShopifyExportImporter implements Importer
: null; : null;
$this->priceResolver->resolve($variant, $currency, $price, $comparePrice); $this->priceResolver->resolve($variant, $currency, $price, $comparePrice);
// Shopify's own "Variant Image" column — the one image a variant picker
// actually swaps to when that variant is selected — distinct from the
// product's full gallery (imageRows below). Often the same file as one
// of the product's own image rows, sometimes not yet imported at all
// (e.g. a variant-only image never listed as its own image row) — either
// way resolveOrImportImage() handles both via the same Image Src dedup
// key, so whichever of importVariant()/importImage() runs first for a
// given src does the actual import.
$variantImageSrc = trim((string) ($row['Variant Image'] ?? ''));
if ($variantImageSrc !== '') {
$media = $this->resolveOrImportImage($product, $handle, $variantImageSrc, 1, $imagesPath);
if ($media) {
$variant->images()->syncWithoutDetaching([
$media->id => ['primary' => true, 'position' => 1],
]);
}
}
} }
private function importImage( private function importImage(
@@ -191,29 +213,54 @@ class ShopifyExportImporter implements Importer
array $row, array $row,
string $imagesPath, string $imagesPath,
): void { ): void {
$externalId = $row['Image Src'] ?: "{$handle}#image-{$index}";
$position = (int) ($row['Image Position'] ?? $index + 1); $position = (int) ($row['Image Position'] ?? $index + 1);
if (ImportMapping::resolve(self::SOURCE, 'image', $externalId)) { $this->resolveOrImportImage($product, $handle, $row['Image Src'], $position, $imagesPath);
return; }
/**
* Resolves the Media already imported for $imageSrc (recorded under
* source_type 'image', keyed by Image Src — the same URL Shopify repeats
* across a product's own image rows and any variant's "Variant Image"
* column), importing it via AssetResolver if this is the first time this
* src has been seen. Shared by importImage() (product gallery) and
* importVariant() (variant-specific image) so the same physical file is
* never uploaded to Spatie MediaLibrary twice just because Shopify's flat
* CSV format repeats the URL on multiple rows.
*/
private function resolveOrImportImage(
Product $product,
string $handle,
string $imageSrc,
int $position,
string $imagesPath,
): ?Media {
$externalId = $imageSrc ?: "{$handle}#image-{$position}";
$existing = ImportMapping::resolve(self::SOURCE, 'image', $externalId);
if ($existing instanceof Media) {
return $existing;
} }
$localFile = $this->findLocalFile($imagesPath, $row['Image Src']); $localFile = $this->findLocalFile($imagesPath, $imageSrc);
if ($localFile === null) { if ($localFile === null) {
Log::warning('Shopify import: image file not found', [ Log::warning('Shopify import: image file not found', [
'handle' => $handle, 'handle' => $handle,
'image_src' => $row['Image Src'], 'image_src' => $imageSrc,
]); ]);
return; return null;
} }
$media = $this->assetResolver->resolve($product, $localFile, $position); $media = $this->assetResolver->resolve($product, $localFile, $position);
if ($media) { if ($media) {
ImportMapping::record(self::SOURCE, 'image', $externalId, $product); ImportMapping::record(self::SOURCE, 'image', $externalId, $media);
} }
return $media;
} }
private function findLocalFile(string $imagesPath, string $imageSrc): ?string private function findLocalFile(string $imagesPath, string $imageSrc): ?string
@@ -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\DTOs\CaptureResult;
use Modules\Core\Payment\DTOs\RefundResult;
/**
* Writes the Transaction row a SupportsRefunds/SupportsCaptures driver's
* result becomes — the one place that translates a gateway-agnostic
* RefundResult/CaptureResult into Lunar's own transactions table, in the
* same shape lunarphp/stripe's StoreCharges already writes (type, success,
* amount, reference, driver, notes). Kept here rather than inside each
* driver so every driver's rows land in a consistent shape that
* Order::paymentStatus() and TransactionObserver both already understand,
* without any driver needing to know about either.
*/
class TransactionRecorder
{
public function recordRefund(Order $order, string $driver, RefundResult $result, ?string $notes = null): Transaction
{
return $order->transactions()->create([
'success' => $result->success,
'type' => 'refund',
'driver' => $driver,
'amount' => $result->amount,
'reference' => $result->reference,
'status' => $result->success ? 'succeeded' : 'failed',
'notes' => $notes ?? $result->message,
'meta' => $result->meta,
]);
}
public function recordCapture(Order $order, string $driver, CaptureResult $result, ?string $notes = null): Transaction
{
return $order->transactions()->create([
'success' => $result->success,
'type' => 'capture',
'driver' => $driver,
'amount' => $result->amount,
'reference' => $result->reference,
'status' => $result->success ? 'succeeded' : 'failed',
'notes' => $notes ?? $result->message,
'meta' => $result->meta,
]);
}
}
@@ -10,9 +10,9 @@ use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\Contracts\SupportsManifestBatching; use Modules\Core\Shipping\Contracts\SupportsManifestBatching;
use Modules\Core\Shipping\Contracts\SupportsTracking; use Modules\Core\Shipping\Contracts\SupportsTracking;
use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException; use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException;
use Modules\Core\Shipping\DataTransferObjects\ManifestResult; use Modules\Core\Shipping\DTOs\ManifestResult;
use Modules\Core\Shipping\DataTransferObjects\ShipmentRequest; use Modules\Core\Shipping\DTOs\ShipmentRequest;
use Modules\Core\Shipping\DataTransferObjects\TrackingCheckpoint; use Modules\Core\Shipping\DTOs\TrackingCheckpoint;
use Modules\Core\Shipping\Enums\TrackingStatus; use Modules\Core\Shipping\Enums\TrackingStatus;
use Modules\Core\Shipping\Models\Shipment; use Modules\Core\Shipping\Models\Shipment;
+1 -1
View File
@@ -4,7 +4,7 @@ namespace Modules\Core\Shipping\Carriers\Acs;
use Lunar\DataTypes\Price; use Lunar\DataTypes\Price;
use Lunar\DataTypes\ShippingOption; use Lunar\DataTypes\ShippingOption;
use Lunar\Shipping\DataTransferObjects\ShippingOptionRequest; use Lunar\Shipping\DTOs\ShippingOptionRequest;
use Lunar\Shipping\Interfaces\ShippingRateInterface; use Lunar\Shipping\Interfaces\ShippingRateInterface;
use Lunar\Shipping\Models\ShippingRate; use Lunar\Shipping\Models\ShippingRate;
use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException; use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException;
@@ -8,8 +8,8 @@ use Lunar\Models\Order;
use Modules\Core\Shipping\Carriers\BoxNow\Exceptions\BoxNowApiException; use Modules\Core\Shipping\Carriers\BoxNow\Exceptions\BoxNowApiException;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface; use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\Contracts\SupportsTracking; use Modules\Core\Shipping\Contracts\SupportsTracking;
use Modules\Core\Shipping\DataTransferObjects\ShipmentRequest; use Modules\Core\Shipping\DTOs\ShipmentRequest;
use Modules\Core\Shipping\DataTransferObjects\TrackingCheckpoint; use Modules\Core\Shipping\DTOs\TrackingCheckpoint;
use Modules\Core\Shipping\Enums\TrackingStatus; use Modules\Core\Shipping\Enums\TrackingStatus;
use Modules\Core\Shipping\Models\Shipment; use Modules\Core\Shipping\Models\Shipment;
@@ -3,7 +3,7 @@
namespace Modules\Core\Shipping\Carriers\BoxNow; namespace Modules\Core\Shipping\Carriers\BoxNow;
use Lunar\DataTypes\ShippingOption; use Lunar\DataTypes\ShippingOption;
use Lunar\Shipping\DataTransferObjects\ShippingOptionRequest; use Lunar\Shipping\DTOs\ShippingOptionRequest;
use Lunar\Shipping\Interfaces\ShippingRateInterface; use Lunar\Shipping\Interfaces\ShippingRateInterface;
use Lunar\Shipping\Models\ShippingRate; use Lunar\Shipping\Models\ShippingRate;
use Modules\Core\Shipping\Concerns\ResolvesFixedPricing; use Modules\Core\Shipping\Concerns\ResolvesFixedPricing;
@@ -3,7 +3,7 @@
namespace Modules\Core\Shipping\Contracts; namespace Modules\Core\Shipping\Contracts;
use Lunar\Models\Order; use Lunar\Models\Order;
use Modules\Core\Shipping\DataTransferObjects\ShipmentRequest; use Modules\Core\Shipping\DTOs\ShipmentRequest;
use Modules\Core\Shipping\Models\Shipment; use Modules\Core\Shipping\Models\Shipment;
interface CarrierFulfillmentInterface interface CarrierFulfillmentInterface
@@ -3,7 +3,7 @@
namespace Modules\Core\Shipping\Contracts; namespace Modules\Core\Shipping\Contracts;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Modules\Core\Shipping\DataTransferObjects\ManifestResult; use Modules\Core\Shipping\DTOs\ManifestResult;
/** /**
* Optional capability for carriers that batch shipments into a manifest * Optional capability for carriers that batch shipments into a manifest
+1 -1
View File
@@ -3,7 +3,7 @@
namespace Modules\Core\Shipping\Contracts; namespace Modules\Core\Shipping\Contracts;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Modules\Core\Shipping\DataTransferObjects\TrackingCheckpoint; use Modules\Core\Shipping\DTOs\TrackingCheckpoint;
use Modules\Core\Shipping\Models\Shipment; use Modules\Core\Shipping\Models\Shipment;
/** /**
@@ -1,6 +1,6 @@
<?php <?php
namespace Modules\Core\Shipping\DataTransferObjects; namespace Modules\Core\Shipping\DTOs;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
@@ -1,6 +1,6 @@
<?php <?php
namespace Modules\Core\Shipping\DataTransferObjects; namespace Modules\Core\Shipping\DTOs;
/** /**
* Carrier-agnostic input for CarrierFulfillmentInterface::createShipment(). * Carrier-agnostic input for CarrierFulfillmentInterface::createShipment().
@@ -1,6 +1,6 @@
<?php <?php
namespace Modules\Core\Shipping\DataTransferObjects; namespace Modules\Core\Shipping\DTOs;
use Carbon\CarbonInterface; use Carbon\CarbonInterface;
use Modules\Core\Shipping\Enums\TrackingStatus; use Modules\Core\Shipping\Enums\TrackingStatus;
@@ -14,7 +14,7 @@ use Lunar\Admin\Support\Extending\ViewPageExtension;
use Lunar\Models\Order; use Lunar\Models\Order;
use Lunar\Shipping\Models\ShippingMethod; use Lunar\Shipping\Models\ShippingMethod;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface; use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\DataTransferObjects\ShipmentRequest; use Modules\Core\Shipping\DTOs\ShipmentRequest;
class OrderViewExtension extends ViewPageExtension class OrderViewExtension extends ViewPageExtension
{ {