Compare commits

...
22 Commits
Author SHA1 Message Date
arvanitakis 73bfc748b4 Feature: Moving Payment Methods to DB, adding fees, Transaction Updates, Refund Updates, General Updates to Payments 2026-09-09 00:48:09 +03:00
arvanitakis 4ff9bdacc3 Bump version to 0.14.0 2026-09-04 13:06:26 +03:00
arvanitakis 55832d9549 Feat: Adding search results for translation 2026-09-04 13:06:07 +03:00
arvanitakis 7b46a83e5e Feat: Product Search Service Restructure 2026-09-04 13:03:38 +03:00
arvanitakis e9aa08a338 Bump version to 0.13.1 2026-09-03 18:28:22 +03:00
arvanitakis 0676a1f5c7 Feat: Recording Payment Transactions 2026-09-03 18:26:48 +03:00
arvanitakis 8e8ec17d09 Bump version to 0.13.0 2026-09-03 17:44:22 +03:00
arvanitakis e6f1ca179a Fix: Fixing various bugs occured on payment lifecycle 2026-09-03 17:42:30 +03:00
arvanitakis 79e525d53f Fix: Stripe Intents Table is a Lunar Table, so needs a prefix. This is covered by the Lunar\Base\Migration 2026-09-03 17:31:39 +03:00
arvanitakis 456943dc74 Feature: Payment resolver, Payment Provider, Completing Stripe Webhooks, Wiring Payments to checkout service 2026-09-03 17:27:34 +03:00
arvanitakis 35c3334690 Feat: Payment Restructuring to be fully event-driven 2026-09-03 17:00:24 +03:00
arvanitakis a987a2d57c Feature: Abstraction on Payments based on their operations 2026-09-03 16:01:33 +03:00
arvanitakis 0fa2188146 Feat: Refactoring Shipping DataTransferObjects to DTOs namespace 2026-09-03 15:58:51 +03:00
arvanitakis a1301d4b46 Merge branch 'master' into Payments 2026-09-03 13:34:32 +03:00
arvanitakis 215f43f3ef Feat: Adding tags to product filters 2026-09-03 13:34:04 +03:00
arvanitakis c439299144 Bump version to 0.12.1 2026-09-03 13:23:50 +03:00
arvanitakis 6963515971 Fix: Small update to label parsing 2026-09-03 13:22:09 +03:00
arvanitakis f43f72f633 Feat: Updates to Product Indexer, Shopify Importer, Adding cascade delete on reviews 2026-09-03 12:26:00 +03:00
arvanitakis 448da869a9 Bump version to 0.12.0 2026-09-03 11:44:36 +03:00
arvanitakis 1287b513cd Feat: Updating Product Service with new Methods, DTOs for ListingResult And Slider Bounds 2026-09-03 11:44:23 +03:00
arvanitakis b2919f1f4b Feature: Product Search Service Updates 2026-09-03 11:04:19 +03:00
arvanitakis 8cb54e065e Feat: Updates to Payments, Checkout Services, Payment Events 2026-09-02 16:14:52 +03:00
88 changed files with 4069 additions and 562 deletions
+65
View File
@@ -4,6 +4,71 @@ 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.14.0] - 2026-09-03
### Changed
- **Breaking:** `Modules\Core\Catalog\Services\ProductSearchService::search()` now returns `Modules\Core\Catalog\DTOs\ProductListingResult` — the exact same shape `ProductService::list()` already returns — instead of a bare `Illuminate\Database\Eloquent\Collection<Product>` of hydrated models with no pagination at all. New signature: `search(string $query, ?ProductFilters $filters = null, ?ProductSort $sort = null, int $perPage = 24, int $page = 1): ProductListingResult`. `->products` is a real `LengthAwarePaginator` of plain, localized indexed-document arrays (not Eloquent models, not Scout's raw response) — a search results page and a category listing page are now interchangeable from a controller's perspective: same DTO, same `ProductCard::fromIndexed()` mapping, same pagination/sort/tag/price-slider handling. `->priceBounds`/`->availableTags` are scoped to the search query itself (delegated to `ProductService::priceSliderBounds()`/`availableTags()`, both of which already accepted a `$query` param for this).
- `Modules\Core\Catalog\Services\ProductService::availableTags()` is now `public` (was `private`) and takes an optional `$query` parameter, so `ProductSearchService::search()` can reuse it directly instead of reimplementing the same facet call.
### Added
- `Modules\Core\Catalog\Support\ProductDocumentLocalizer` — the per-locale field resolution and raw-Meilisearch-response unwrapping (`withLocalizedFields()`, `hitsFrom()`) extracted out of `ProductService` into its own class, since `ProductSearchService` needed the exact same logic against the exact same kind of document. Both services now depend on this one class instead of `ProductService` owning logic a second service also needed.
## [0.13.0] - 2026-09-03
### Changed
- **Breaking:** `Payment` is now a genuinely standalone module — no direct calls into `Checkout`/`Order`, no reaching into their Eloquent models, communication only via events. The entire old `confirm()`-based flow is gone: `Modules\Core\Payment\Contracts\PaymentDriver` (and the already-stale `Modules\Core\Checkout\Contracts\PaymentDriver` duplicate), `Checkout\Events\PaymentConfirmed`, `Payment\Contracts\InitiatesPayment`, `Payment\DataTransferObjects\PaymentInitiation`, `Payment\Enums\PaymentInitiationMode`, `Payment\Events\PaymentSucceeded`/`PaymentFailed`, `Payment\Events\OrderPaymentStatusResolved`, and `Payment\Exceptions\PaymentNotConfirmedException` are all deleted. This flow was non-functional on `master` before this release — `CheckoutService::confirmPayment()` dispatched an event nothing listened for, so no order was ever placed after payment.
- **Breaking:** Every payment operation is now its own explicit, opt-in contract, modeled on how real gateways (Stripe, Mastercard's own gateway, Nexi) actually split these operations — see `docs/payments.md`: `Modules\Core\Payment\Contracts\SupportsPay` (atomic authorize+capture), `SupportsAuthorization` (hold only), `SupportsCaptures` (settle a prior hold), `SupportsVoids` (release a prior hold without settling), `SupportsRefunds` (reverse settled funds), `HandlesPaymentCallback` (resolve an async pay()/authorize() later, from a webhook), and `Configurable` (`isConfigured()`, split out of the old single `PaymentDriver` interface). A driver implements only the operations its gateway actually supports.
- **Breaking:** Every amount flowing through these contracts is `Lunar\DataTypes\Price` (Lunar's own bundled minor-unit-value + `Currency` type) — never a bare `int` paired separately with a `Currency`. Each driver converts at its own boundary (e.g. `StripeManager::toStripeAmount()`/`fromStripeAmount()`); `Payment` itself only ever speaks Lunar's `Price`.
- **Breaking:** `Modules\Core\Checkout\Services\CheckoutService::placeOrder()` and `confirmPayment()` are both replaced by a single `initiatePayment(string $fingerprint, array $data = []): Modules\Core\Payment\DTOs\PaymentResult`. It creates the draft `Order` (`Cart::createOrder()`, idempotent against an existing draft) and hands off directly to the resolved driver's `pay()`/`authorize()`, per that type's new `config('lunar.payments.types.{type}.capture_mode')` key. `Checkout\Events\OrderPlaced` no longer dispatches from `CheckoutService` — it now fires from `Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus` once a `PaymentCaptured`/`PaymentAuthorized` event actually transitions the order's `placed_at`, since a draft order can now exist well before payment resolves (an async gateway).
- `Modules\Core\Payment\Services\PaymentDriverResolver::resolve()` now returns `?object` instead of the deleted `PaymentDriver` interface — a driver implements several independent capability interfaces at once, so a caller does its own `instanceof SupportsPay`/`instanceof SupportsAuthorization` check, the same pattern the capability interfaces themselves are designed around.
- `config/payment.php`'s `cash-on-delivery` entry gains `capture_mode` (`'pay'`, since `OfflinePaymentDriver` only implements `SupportsPay`) and `captured_status` (`'payment-offline'`, replacing the previously dead `'authorized' => 'awaiting-payment'` key, which nothing ever read).
### Added
- `Modules\Core\Payment\DTOs\PaymentResult` — the one return shape every operation (`pay`, `authorize`, `capture`, `void`, `refund`, `handleCallback`) produces, regardless of gateway: `status` (`Modules\Core\Payment\Enums\PaymentResultStatus`: `Succeeded`/`Failed`/`Pending`), `reference`, `amount` (a `Price`), `failureReason`, `retriable` (real on Stripe/Mastercard's own soft-decline classification, always `false` on Nexi — it has no such signal), `raw` (the untouched gateway response, for audit), `meta`, and `continuation` (see below).
- `Modules\Core\Payment\DTOs\PaymentContinuation` / `Modules\Core\Payment\Enums\PaymentContinuationType` — what a caller does next with a `Pending` `PaymentResult`, gateway-agnostically (`Redirect` or `ClientSecret`), so a storefront controller never needs gateway-specific knowledge of e.g. Stripe's own `PaymentIntent` fields to drive a 3-D Secure/redirect continuation.
- Eight new events, one terminal pair per operation, replacing the old single `PaymentSucceeded`/`PaymentFailed`: `PaymentAuthorized`/`PaymentAuthorizationFailed`, `PaymentCaptured`/`PaymentCaptureFailed`, `PaymentVoided`/`PaymentVoidFailed`, `PaymentRefunded`/`PaymentRefundFailed`. `PaymentCaptured` is deliberately the same event whether money was taken via `pay()` (one gateway call) or `authorize()`→`capture()` (two calls) — "a payment has been captured" is the same business fact either way. Every event carries `{type, result: PaymentResult, context}` — `context` is an opaque bag the caller hands in and gets back untouched, so `Payment` never needs to know what a `Cart` or `Order` is.
- `Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus` (rewired, not new — previously listened to the now-deleted `OrderPaymentStatusResolved`) is the only place an `Order`'s `status` column is written in reaction to a payment outcome: it listens to `PaymentCaptured`/`PaymentAuthorized` directly, reads `$event->context['order_id']`, and resolves the new status from `config('lunar.payments.types.{type}.captured_status')`/`authorized_status`.
- `Modules\Core\Payment\Drivers\StripePaymentDriver` rewritten onto the new contracts — implements all six capability interfaces plus `Configurable`. Solves `handleCallback()`'s async-correlation problem (a webhook is a separate HTTP request from the `pay()`/`authorize()` call that started it) the same way `lunarphp/stripe`'s own `StripePaymentType`/`ProcessStripeWebhook` do: real `cart_id`/`order_id` columns on `Lunar\Stripe\Models\StripePaymentIntent`, plus two new columns this driver needs (`context`, `payment_type`) added by a new migration — `database/migrations/2026_09_03_000002_add_context_to_stripe_payment_intents.php`.
- `Modules\Core\Payment\Http\Controllers\StripeWebhookController` + `src/Payment/routes/webhooks.php` (`POST /payments/stripe/webhook`, loaded by `PaymentServiceProvider`) — a boboko-owned webhook endpoint, deliberately not `lunarphp/stripe`'s own route (which dispatches into Lunar's own `Payments::driver('stripe')` flow, the flow this driver replaces). Reuses `Lunar\Stripe\Http\Middleware\StripeWebhookMiddleware` and `Stripe\Webhook::constructEvent()` directly — both are genuine Stripe SDK signature verification, safe to reuse without touching the rest of that vendor package's flow. Requires `config('services.stripe.webhooks.lunar')` set in a consuming app; no `stripe` config type entry is added to `config/payment.php` in this release — enabling Stripe for real is a follow-up.
- `docs/payments.md` — full design notes: the operation/contract table cross-referenced against Mastercard/Stripe/Nexi's real APIs, why `PaymentResult` normalizes only what every gateway can always provide, the async-correlation pattern, and what's explicitly out of scope (a `Transaction`-writing listener, the `stripe` config entry, frontend Stripe Elements integration).
### Fixed
- `Modules\Core\Checkout\Services\CheckoutService::selectPaymentMethod()` crashed (`Call to a member function toArray() on null`) the first time it ran against a cart whose `meta` column was still a genuine SQL `NULL` (any freshly-created cart) — `Cart::$meta`'s `AsArrayObject` cast returns `null`, not an empty array-like object, for a `null` column. Fixed with a null-safe fallback.
- `Modules\Core\Shipping\Carriers\Acs\AcsRateDriver`/`BoxNowRateDriver` referenced `Lunar\Shipping\DTOs\ShippingOptionRequest`, a namespace that doesn't exist in the installed `lunarphp/table-rate-shipping` version (the real class is `Lunar\Shipping\DataTransferObjects\ShippingOptionRequest`) — crashed `Illuminate\Support\Manager`'s interface-compatibility check the moment anything touched `ShippingManager::getSupportedDrivers()`, including simply adding a line to a cart (via `Modules\Core\Shipping\Listeners\FlushLivePricingCache`).
## [0.13.1] - 2026-09-03
### Added
- `Modules\Core\Order\Listeners\RecordPaymentTransaction` — writes the `lunar_transactions` row for a successful `PaymentCaptured`/`PaymentAuthorized`/`PaymentVoided`/`PaymentRefunded` event, via a new `Modules\Core\Order\Services\TransactionRecorder` (moved here from `Payment\Services`, and rewritten to take a `PaymentResult` directly instead of the deleted `CaptureResult`/`RefundResult` DTOs — `Payment` never writes to `Order`'s models, `Transaction.order_id` being required is exactly why this lives in `Order`, same reasoning as `ApplyResolvedPaymentStatus`). Closes a real gap introduced in `0.13.0`: `Order::paymentStatus()` (which derives its answer entirely from `$order->transactions`) always resolved to `PaymentStatus::Offline` — its "no transactions at all" fallback — regardless of what actually happened, since nothing had ever written a row. Verified live: a captured offline payment now produces a `type: capture` transaction and `Order::paymentStatus()` correctly resolves to `captured`.
## [0.12.1] - 2026-09-03
### Fixed
- `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
### 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.11.1", "version": "0.14.0",
"autoload": { "autoload": {
"psr-4": { "psr-4": {
"Modules\\Core\\": "src/" "Modules\\Core\\": "src/"
+9 -27
View File
@@ -1,35 +1,8 @@
<?php <?php
use Modules\Core\Payment\Drivers\OfflinePaymentDriver;
use Modules\Core\Payment\Pipelines\Cart\ApplyCashOnDeliveryFee; use Modules\Core\Payment\Pipelines\Cart\ApplyCashOnDeliveryFee;
return [ return [
/*
|--------------------------------------------------------------------------
| Lunar payment types merged in by Boboko Core
|--------------------------------------------------------------------------
|
| These are merged into config('lunar.payments.types') so every app using
| boboko-core gets cash-on-delivery out of the box, without publishing
| Lunar's own config.
|
| 'payment_driver' is boboko-owned, alongside Lunar's own 'driver' key —
| it's the Modules\Core\Checkout\Contracts\PaymentDriver class
| CheckoutService::confirmPayment() resolves via the container and calls
| confirm() on. Kept on the same row as 'driver' rather than a second,
| separately-keyed map, so a type's full definition — Lunar's driver,
| its config, and its PaymentDriver — lives in one place.
|
*/
'types' => [
'cash-on-delivery' => [
'driver' => 'offline',
'payment_driver' => OfflinePaymentDriver::class,
'authorized' => 'awaiting-payment',
'fee' => 0,
],
],
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Lunar cart pipeline additions | Lunar cart pipeline additions
@@ -39,6 +12,15 @@ return [
| the cash-on-delivery fee is added to the shipping total before the | the cash-on-delivery fee is added to the shipping total before the
| final Calculate step sums everything up. | final Calculate step sums everything up.
| |
| This is the one thing left in this file — everything about WHICH
| payment methods exist (driver mapping, capture_mode, statuses) moved
| onto Modules\Core\Payment\Models\PaymentMethod's own row (see
| docs/payments.md): that's a per-instance, merchant decision, not a
| store-wide-singular setting, so it never belonged in config at all.
| This pipeline registration IS genuinely cross-cutting — every store
| using this driver gets the same cart-pipeline wiring, regardless of
| how many payment methods it configures.
|
*/ */
'cart_pipeline' => [ 'cart_pipeline' => [
ApplyCashOnDeliveryFee::class, ApplyCashOnDeliveryFee::class,
@@ -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');
});
}
};
@@ -0,0 +1,47 @@
<?php
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Lunar\Base\Migration;
/**
* lunarphp/stripe's own stripe_payment_intents table already correlates a
* Stripe intent back to a cart/order via cart_id/order_id — exactly what
* Modules\Core\Payment\Drivers\StripePaymentDriver needs to recover
* $context in handleCallback(), a separate request (a webhook) from the
* pay()/authorize() call that originated it. Two columns this driver
* needs that the vendor table doesn't have:
* - context: the full opaque $context bag pay()/authorize() received,
* stored so handleCallback() can dispatch the SAME context the
* original call would have, without Payment inventing its own
* correlation table — see docs/payments.md "Async resolution".
* - payment_type: the payment type key (e.g. 'stripe') pay()/authorize()
* were called with — needed to dispatch Payment events with the
* correct $type in handleCallback(), which otherwise has no way to
* know it (a webhook payload doesn't carry it).
*
* Extends Lunar\Base\Migration (not the plain base Migration) so $this->prefix
* resolves the SAME table-prefix config every Lunar-owned table uses
* (config('lunar.database.table_prefix')) — the vendor migration that
* creates this table (lunarphp/stripe's create_stripe_payment_intents_table)
* already does this, so a store running with a non-default prefix (this
* one runs with 'lunar_') would otherwise have this migration fail against
* a table name that doesn't exist.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table($this->prefix.'stripe_payment_intents', function (Blueprint $table) {
$table->json('context')->nullable()->after('status');
$table->string('payment_type')->nullable()->after('context');
});
}
public function down(): void
{
Schema::table($this->prefix.'stripe_payment_intents', function (Blueprint $table) {
$table->dropColumn(['context', 'payment_type']);
});
}
};
@@ -0,0 +1,52 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Moves the driver mapping and per-type behavior that used to live in
* config('lunar.payments.types.{type}.*') onto the PaymentMethod row
* itself — same DB-instance-vs-config split Modules\Core\Shipping's own
* shipping_methods table already has (code/driver/name/enabled columns,
* no driver mapping in any config file). See docs/payments.md.
*
* - driver: the Modules\Core\Payment\Services\PaymentDriverRegistry key
* (NOT the same as `type` — two rows can share one driver).
* - name: admin-facing label. Nothing played this role before; `type`
* was always the machine slug.
* - capture_mode / captured_status / authorized_status: per-instance
* behavior — fails the "would a store ever want two different answers
* to this" cross-cutting-config test, so these move off config.
* - position: admin-controlled display/checkout order.
* - driver_missing_at: set by the payment:sync-drivers command when
* `driver` no longer resolves via the registry — deliberately
* separate from `enabled`, so a driver vanishing (a deploy removed
* it) is never confused with an admin's own manual toggle, and a
* driver that comes back later auto-clears this with no admin action.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('payment_methods', function (Blueprint $table) {
$table->string('name')->nullable()->after('type');
$table->string('driver')->nullable()->after('name');
$table->string('capture_mode')->nullable()->after('driver');
$table->string('captured_status')->nullable()->after('capture_mode');
$table->string('authorized_status')->nullable()->after('captured_status');
$table->unsignedInteger('position')->default(0)->after('authorized_status');
$table->timestamp('driver_missing_at')->nullable()->after('position');
});
}
public function down(): void
{
Schema::table('payment_methods', function (Blueprint $table) {
$table->dropColumn([
'name', 'driver', 'capture_mode', 'captured_status',
'authorized_status', 'position', 'driver_missing_at',
]);
});
}
};
@@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* captured_status/authorized_status (added in 2026_09_05_000001) cover a
* payment being taken, but nothing wrote Order.status on a REFUND —
* Order::paymentStatus() (Order\Support\OrderStatus::payment(), derived
* live from transactions) already reflects a refund correctly, but the
* stored status column — the one admin filtering, customer emails, etc.
* actually key off — never moved. Same reasoning as captured_status/
* authorized_status: a store could plausibly want a different resulting
* status per payment method (e.g. a "Refunded" vs. a "Refund Pending"
* variant), so this is a PaymentMethod column, not cross-cutting config.
*
* Deliberately no separate void_status — void never moved money (it
* releases an authorization hold before any capture), so it doesn't carry
* the same "the customer needs to see this changed" weight a refund does;
* add one later if a real need for it shows up.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('payment_methods', function (Blueprint $table) {
$table->string('refunded_status')->nullable()->after('authorized_status');
});
}
public function down(): void
{
Schema::table('payment_methods', function (Blueprint $table) {
$table->dropColumn('refunded_status');
});
}
};
+189
View File
@@ -0,0 +1,189 @@
# Payment — Design Notes
**Status: abstraction layer built, drivers/wiring in progress.** `Payment` is designed as a
standalone module: it never calls into `Checkout` or `Order`, never touches their Eloquent
models, and communicates only via events. This document is the design spec for that
abstraction — contracts, DTOs, events — independent of how `Checkout`/`Order` end up consuming
it (that wiring is a separate, later pass).
---
## Operations, not gateways
The driver contracts model the actual operations a payment gateway can perform, not vendor
terminology. Every real gateway checked while designing this converges on the same small set
under different names:
| Operation | Mastercard | Stripe | Nexi |
|---|---|---|---|
| Atomic charge (authorize+capture in one call) | `Pay` | `capture_method: automatic` | `ActionType::PAY()` |
| Hold only, settle/release later | `Authorize` | `capture_method: manual` | `ActionType::PREAUTH()` |
| Settle a prior hold | `Capture` | `PaymentIntent::capture()` | `CaptureRequest`/`CaptureResponse` |
| Release a prior hold without settling | `Void`/`Cancel` | `PaymentIntent::cancel()` | `CancelRequest`/`CancelResponse` |
| Reverse settled funds | `Refund` | `Refund::create()` | (refund endpoint) |
A driver implements only the interfaces its gateway actually supports:
- An offline/cash type (`cash-on-delivery`, `cash-in-hand`) only ever settles atomically —
implements `SupportsPay` alone.
- A card gateway capable of either mode per-transaction (Stripe, most card processors)
implements `SupportsPay`, `SupportsAuthorization`, `SupportsCaptures`, `SupportsVoids`, and
`SupportsRefunds` all at once — which one gets *called* for a given attempt is the caller's
policy choice (e.g. `config('lunar.stripe.policy')`), not something baked into the driver's
shape.
- A redirect/wallet gateway with no separate hold step (Viva/Klarna in typical flows)
implements `SupportsPay` and `SupportsRefunds`, never `SupportsCaptures`/`SupportsVoids`.
### `pay()` and `authorize()` stay separate methods even when a gateway implements both as "the same call with a flag"
Stripe has no separate `authorize`/`pay` API endpoints — one `PaymentIntent`, confirmed with
either `capture_method: automatic` or `manual`. Mastercard and Nexi *do* have genuinely
separate operations. The contract abstracts over both shapes uniformly: every driver capable
of both exposes two distinct methods, `pay()` and `authorize()`. A Mastercard-style driver
calls two different endpoints under the hood; a Stripe-style driver calls the same endpoint
twice with a different flag each time. Neither difference is visible to a caller.
### `capture()`/`void()` are only ever valid against a prior `authorize()`
They are not standalone operations — `capture()` settles a specific hold identified by the
`reference` `authorize()` returned; `void()` releases that same hold instead. A driver that
never implements `SupportsAuthorization` never produces a reference either of these methods
could act on.
---
## `PaymentResult` — the one return shape, every operation, every driver
```php
enum PaymentResultStatus { case Succeeded; case Failed; case Pending; }
final class PaymentResult {
public function __construct(
public readonly PaymentResultStatus $status,
public readonly string $reference,
public readonly int $amount,
public readonly ?string $failureReason = null,
public readonly bool $retriable = false,
public readonly array $raw = [],
public readonly array $meta = [],
) {}
}
```
Real gateway responses vary wildly in richness — confirmed by reading three SDKs directly:
- **Stripe's `PaymentIntent`** is rich: `status`, `amount`, `amount_capturable`,
`amount_received`, `last_payment_error`, a full `getLastResponse()`.
- **Nexi's `CaptureResponse`/`CancelResponse`** are minimal: just `operationId` +
`operationTime` — no echoed amount or status at all. Success is inferred from getting a
response rather than an SDK exception.
- **Mastercard's** gateway sits in between, with `gatewayCode`/`acquirerCode`/
`merchantAdviceCode`.
`PaymentResult` only requires what every driver can always know: `status`, `reference`,
`amount` (the amount **we** requested — not necessarily echoed back by a sparse gateway like
Nexi's capture). Everything else is best-effort: `failureReason`/`retriable` are normalized
only when the gateway has something to normalize from; `raw` is the unconditional escape
hatch — the untouched gateway response body, always populated, for genuine audit fidelity
regardless of how sparse the normalized fields ended up.
### `retriable` — real on some gateways, absent on others
Stripe classifies declines as soft (`do_not_honor`, `insufficient_funds` — worth retrying,
after a delay) vs. hard (`stolen_card`, `expired_card` — never retry the same method).
Mastercard has the equivalent via `authorizationResponse.merchantAdviceCode` and card-scheme
soft-decline codes. **Nexi has no such signal at all** — `OperationResult` is just
`DECLINED`/`DENIED_BY_RISK`/`FAILED`/etc. with no retriability classification. `retriable`
therefore defaults to `false` (assume not safely retriable) rather than guessing when a
driver's gateway has nothing to base it on.
---
## Events — one terminal pair per operation, keyed to the business fact, not the call path
`Modules\Core\Payment\Events`:
| Event pair | Dispatched by |
|---|---|
| `PaymentAuthorized` / `PaymentAuthorizationFailed` | `SupportsAuthorization::authorize()`, or a later `HandlesPaymentCallback::handleCallback()` resolving it |
| `PaymentCaptured` / `PaymentCaptureFailed` | `SupportsPay::pay()` **or** `SupportsCaptures::capture()` |
| `PaymentVoided` / `PaymentVoidFailed` | `SupportsVoids::void()` |
| `PaymentRefunded` / `PaymentRefundFailed` | `SupportsRefunds::refund()` |
`PaymentCaptured` is deliberately the *same* event whether money was taken via `pay()` (one
gateway call) or `authorize()` → `capture()` (two calls) — "a payment has been captured" is
the same business fact either way, and a listener reacting to it never needs to know which
path produced it. There is no separate "payment succeeded" wrapper event distinct from
`PaymentCaptured`.
Every event carries `{type: string, result: PaymentResult, context: array}`. `Payment` has no
concept of a `Cart`, an `Order`, or a checkout fingerprint — `$context` is an opaque bag the
caller hands in on the way down (`pay($type, $data, $context)`) and gets back untouched on
whichever event that call (or a later `handleCallback()`) produces. Each listener interprets
`$context` on its own terms, or ignores the event if the keys it needs aren't present —
`Checkout` is only one possible consumer of these events, not the only one.
---
## Async resolution — `HandlesPaymentCallback`
Only implemented by a driver whose `pay()`/`authorize()` can return `PaymentResultStatus::Pending`
— a redirect the shopper completes elsewhere, a webhook that arrives later. A driver whose
gateway always resolves synchronously never implements this.
```php
public function handleCallback(string $reference, array $data, array $context = []): PaymentResult;
```
Resolves into the *same* event pair the original `pay()`/`authorize()` call would have
produced had it resolved synchronously.
### The correlation problem: `handleCallback()` runs in a different request
`$context` passed into the original `pay()`/`authorize()` call does not survive to
`handleCallback()` on its own — that call is typically a separate HTTP request (a webhook)
with no memory of the request that started the payment. Something has to persist enough to
answer "which order/cart does gateway reference X belong to?" between the two calls.
**Read directly from `lunarphp/stripe`'s own source** (`StripePaymentType::authorize()`,
`ProcessStripeWebhook`, `WebhookController`) to see how Lunar itself solves this — confirmed
it does **not** stash a generic opaque blob. It writes the correlating ids as real, typed
columns on `Lunar\Stripe\Models\StripePaymentIntent` (`cart_id`, `order_id`) at the moment the
intent is created/first seen, then reads them back the same way when the webhook arrives:
```php
// ProcessStripeWebhook::handle() — falls back through two real lookups,
// neither of them a generic context blob:
$cart = StripePaymentIntent::where('intent_id', $this->paymentIntentId)->first()?->cart
?: Cart::where('meta->payment_intent', '=', $this->paymentIntentId)->first();
```
**`StripePaymentDriver` follows this exact precedent**: it reads `cart_id`/`order_id` out of
`$context` at `pay()`/`authorize()` time and writes them onto its own `StripePaymentIntent`
row (a table already owned by `lunarphp/stripe`, already shaped for exactly this), then reads
them back the same way in `handleCallback()`. No generic `context` json column, no new table.
### This pattern is per-driver, not a shared table
`stripe_payment_intents` is Stripe-specific — keyed on `intent_id`, typed around
`Stripe\PaymentIntent`'s own status values. It cannot be reused as-is for a future non-Stripe
async driver (Nexi, Viva): that driver's own gateway reference has a different shape entirely,
and shoehorning it into Stripe-named columns would make the table misleading. The **pattern**
generalizes — *any* driver needing async callback resolution owns a small table keyed by its
own gateway's reference, storing whatever correlation data that driver specifically needs —
but each driver gets its own table, matching what it actually needs to correlate, rather than
a shared generic one.
---
## Explicitly out of scope for this pass
- **`Checkout`/`Order` wiring** — how `Checkout` calls into `Payment`, how `Order`/`Checkout`
react to `Payment`'s events, where a draft `Order` gets created relative to when `Payment` is
called. Deliberately designed and built separately, after `Payment` itself was complete —
`Payment` must stand on its own regardless of what ends up consuming it.
- **`Transaction` persistence** — Lunar's own `transactions` table (`type`: `intent`/`capture`/
`refund`, `parent_transaction_id` chaining) already models the audit trail these events
would feed, once a listener is built to write to it. `Payment` itself does not write
`Transaction` rows — see the events table above; that is a listener's job, in whichever
module ends up owning the write (likely `Order`, since `Transaction.order_id` is required).
+46 -16
View File
@@ -1,8 +1,9 @@
# 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()` — all reading directly from the lookup for a storefront — `list()`, `getById()`, `getBySlug()`, `random()`, `variantSummaries()` —
Meilisearch index rather than the database. One data source for everything this service does. all reading directly from the Meilisearch index rather than the database. One data source for
everything this service does.
This is separate from `Modules\Core\Catalog\Services\ProductSearchService` (see `product-search.md`), which 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.
@@ -28,13 +29,14 @@ use Modules\Core\Catalog\Enums\ProductSort;
$service = app(ProductService::class); $service = app(ProductService::class);
// List everything, paginated — returns a real Illuminate\Pagination\LengthAwarePaginator, // One call for everything a listing page needs — products AND the price slider's
// built from the localized Meilisearch hits (not Scout's own paginateRaw() result — see // bounds together, as a Modules\Core\Catalog\DTOs\ProductListingResult. A caller
// "Meilisearch driver quirk" below), so it behaves like any other Laravel paginator. // used to have to call list() and priceSliderBounds() (or the older priceRange())
$products = $service->list(perPage: 24, page: 1); // separately and glue the results together itself; that's now list()'s own job.
$listing = $service->list(perPage: 24, page: 1);
// Filter by collection, brand, price range, and/or stock // Filter by collection, brand, price range, and/or stock
$products = $service->list( $listing = $service->list(
filters: new ProductFilters(collectionId: 17, minPrice: 10.0, maxPrice: 50.0, inStockOnly: true), filters: new ProductFilters(collectionId: 17, minPrice: 10.0, maxPrice: 50.0, inStockOnly: true),
perPage: 24, perPage: 24,
page: 1, page: 1,
@@ -42,8 +44,13 @@ $products = $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).
$products = $service->list(perPage: 24, page: 1, sort: ProductSort::PriceAsc); $listing = $service->list(perPage: 24, page: 1, sort: ProductSort::PriceAsc);
$products = $listing->products; // a real Illuminate\Pagination\LengthAwarePaginator,
// built from the localized Meilisearch hits (not Scout's
// own paginateRaw() result — see "Meilisearch driver
// quirk" below), so it behaves like any other Laravel
// paginator.
$products->items(); // array of Meilisearch documents (plain arrays, not models) $products->items(); // array of Meilisearch documents (plain arrays, not models)
$products->total(); $products->total();
$products->perPage(); $products->perPage();
@@ -51,12 +58,32 @@ $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,
@@ -64,11 +91,13 @@ $product = $service->getBySlug('erotika-mprelok'); // array, or null if not fo
$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, for sizing a price-range slider. // Min/max price across matching products — the raw, unrounded values list() itself
// minPrice/maxPrice are ALWAYS excluded from the filter driving this (unlike // uses to build priceBounds above. minPrice/maxPrice are ALWAYS excluded from the
// facets(), which doesn't auto-exclude) — the slider's own bounds shouldn't shrink // filter driving this (unlike facets(), which doesn't auto-exclude) — the slider's
// to whatever range is currently selected on it. Other filters (collectionId, // own bounds shouldn't shrink to whatever range is currently selected on it. Other
// brand, inStockOnly) still apply normally. // filters (collectionId, brand, inStockOnly) still apply normally. Pass $query too
// to scope the range to a text search's own matches (see product-search.md) rather
// than the whole catalog.
$range = $service->priceRange(new ProductFilters(collectionId: 17)); $range = $service->priceRange(new ProductFilters(collectionId: 17));
// ['min' => 0.0, 'max' => 120.0] // ['min' => 0.0, 'max' => 120.0]
``` ```
@@ -77,8 +106,8 @@ All `ProductFilters` fields are optional; only the ones set are added to the Mei
`facets()` only makes sense on discrete-value filterable fields (`brand`, `in_stock`) — a numeric `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()` for `price` instead, which reads Meilisearch's `facetStats` (min/max), a different `priceRange()` (or `list()`'s own `priceBounds`) for `price` instead, which reads Meilisearch's
feature from `facetDistribution`. `facetStats` (min/max), a different feature from `facetDistribution`.
--- ---
@@ -106,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. |
+39 -13
View File
@@ -24,36 +24,62 @@ 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():
$results = app(ProductSearchService::class)->search('running shoes', 'el'); // Filters/sort apply the exact same semantics ProductService::list() uses for
// collection browsing (same ProductFilterBuilder, same ProductSort) — a shopper
// narrowing a text search by price/brand/stock gets identical filter behavior
// to narrowing a category listing.
$results = app(ProductSearchService::class)->search(
'running shoes',
filters: new ProductFilters(brand: 'Acme', minPrice: 20.0, inStockOnly: true),
sort: ProductSort::PriceAsc,
);
``` ```
Returns an `Illuminate\Database\Eloquent\Collection` of `Lunar\Models\Product` — Scout's 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.
`$locale` defaults to `App::getLocale()` — already set correctly on every storefront request by There is no `$locale` parameter — see "Field list is dynamic, not hardcoded" below for why
`Modules\Core\Localization\Middleware\LocaleMiddleware` (see `localization.md`), so callers in controllers every configured store language is always searched, regardless of the current request locale.
don't need to pass it explicitly.
--- ---
## Missing-translation fallback ## Missing-translation fallback, in both directions
If a product was only ever given an English name, `name_el` doesn't exist on that document at 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 `name_el` would make that product invisible to Greek-locale search, even though it's a against the current request's locale field would make that product invisible whenever a shopper's
real catalog item. locale doesn't match the language it happens to be translated into.
To avoid silently hiding incompletely-translated products, `ProductSearchService` targets **both** `ProductSearchService` avoids this by targeting **every configured store language's fields**
the resolved locale's fields **and** the default language's fields (`Lunar\Models\Language::all()`) on every search, not just the current request locale plus the
(`Lunar\Models\Language::getDefault()->code`) — e.g. searching in `el` targets `name_el`, store default — e.g. with `el`/`en` configured, every search targets `name_el`, `name_en`,
`name_en`, `description_el`, `description_en` together (assuming `en` is the default language). `description_el`, `description_en` together, regardless of which locale the shopper is browsing
A product missing an `el` translation still matches via its `en` fields. in. This is deliberately not scoped to "current locale + default locale": if the current locale
already equals the default (a single-language store, or a shopper browsing in the default
language), that pairing collapses to one locale and stops catching anything else — always
searching every configured language avoids that gap in both directions, at the cost of a larger
`attributesToSearchOn` list as the store's language count grows.
---
## Variant option values are searched too
Alongside the locale-suffixed attribute fields, every search also targets
`variants.options.value` directly — e.g. a variant named "Κάπτεν Γαμέρικα" on a "Name" option
matches a search for that text, even though it never appears in the product's own name or
description. This isn't one of Lunar's own attributes (`AttributeManifest` has no entry for it),
so it can't be discovered the way `name`/`description` are — it's a structural field of
`Modules\Core\Catalog\Services\ProductIndexer`'s own document shape (see `ProductIndexer::mapVariant()`),
added here directly. Not locale-suffixed — each option value is stored as one already-resolved
string per variant.
--- ---
+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();
```
@@ -0,0 +1,127 @@
@php
$transaction = $getRecord();
$notes = $transaction->notes ?: ($transaction->meta['notes'] ?? null);
@endphp
@once
@php
$renderPaymentIcons();
@endphp
@endonce
<div
@class([
'text-sm rounded-lg shadow-md border dark:bg-gray-900',
'text-gray-950 dark:text-white',
match($transaction->type){
'refund' => 'border-orange-300',
'intent' => 'border-sky-300',
'capture' => 'border-green-300',
default => 'border-gray-300',
},
'!border-red-500 bg-red-50' => !$transaction->success,
'bg-gray-50' => $transaction->success,
])
>
<div class="p-2 space-y-2">
<div class="px-4 py-2 rounded text-xs bg-white dark:bg-gray-800 shadow text-gray-600 dark:text-gray-400 ring-1 ring-gray-100 dark:ring-gray-700">
<span>{{ $transaction->driver }}</span> //
<span>{{ $transaction->reference }}</span>
</div>
<div class="flex items-center justify-between p-4 bg-white dark:bg-gray-800 rounded shadow ring-1 ring-gray-100 dark:ring-gray-700">
<div class="flex items-center gap-6">
<div>
<strong class="text-xs">
{{ $transaction->status }}
</strong>
</div>
<div>
<svg viewBox="0 0 50 50" class="w-10">
<use xlink:href="#{{ strtolower($transaction->card_type) }}"></use>
</svg>
</div>
@if($transaction->last_four)
<p class="text-sm">
<span class="inline-block -translate-y-px">
&lowast;&lowast;&lowast;&lowast; &lowast;&lowast;&lowast;&lowast; &lowast;&lowast;&lowast;&lowast;
</span>
<span class="font-medium">
{{ (string) $transaction->last_four }}
</span>
</p>
@endif
</div>
<strong
@class([
"text-sm",
'text-red-500' => !$transaction->success,
match($transaction->type){
'refund' => "text-orange-500",
default => "text-gray-900 dark:text-gray-100",
},
])
>
@if($transaction->type == 'refund')-@endif{{ $transaction->amount->formatted }}
</strong>
</div>
<div class="px-4 py-2 bg-white dark:bg-gray-800 shadow rounded flex items-center justify-between text-gray-600 dark:text-gray-400 ring-1 ring-gray-100 dark:ring-gray-700">
<div class="text-xs flex items-center gap-2">
<div>
<x-filament::icon
icon="heroicon-o-clock"
class="w-4"
/>
</div>
<span>{{ $transaction->created_at->format('jS F Y h:ia') }}</span>
</div>
<div class="flex space-x-2">
@foreach($transaction->paymentChecks() as $check)
<x-filament::badge
:icon="$check->successful ? 'heroicon-m-check' : 'heroicon-m-x-mark'"
:color="$check->successful ? \Filament\Support\Colors\Color::Sky : 'gray'"
>
{{ $check->label }}: {{ $check->message }}
</x-filament::badge>
@endforeach
</div>
</div>
@if($notes)
<div class="px-4 py-2 bg-white dark:bg-gray-800 shadow flex items-center rounded gap-2 ring-1 ring-gray-100 dark:ring-gray-700">
<div>
<x-filament::icon
icon="heroicon-o-chat-bubble-oval-left-ellipsis"
class="w-4"
/>
</div>
<p class="text-sm">{{ $notes }}</p>
</div>
@endif
</div>
<div
@class([
"bottom-0 left-0 block w-full text-center rounded-b-lg border-t text-xs py-1",
"!bg-red-50 !dark:bg-red-400/10 !border-red-300 !text-red-600 !dark:text-red-400" => !$transaction->success,
match($transaction->type){
'refund' => "bg-orange-50 dark:bg-orange-400/10 border-orange-300 text-orange-600 dark:text-orange-400",
'intent' => "bg-sky-50 dark:bg-sky-400/10 border-sky-300 text-sky-600 dark:text-sky-400",
'capture' => "bg-green-50 dark:bg-green-400/10 border-green-300 text-green-600 dark:text-green-400",
default => "bg-gray-50 dark:bg-gray-400/10 border-gray-300 text-gray-600 dark:text-gray-400",
},
])
>
@if(!$transaction->success)
{{ __('lunarpanel::order.transactions.failed') }}
@else
{{ __('lunarpanel::order.transactions.'.$transaction->type) }}
@endif
</div>
</div>
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace Modules\Core\Catalog\DTOs;
/**
* A price range slider's bounds and whether it's currently narrowed —
* built by ProductService::priceSliderBounds(), which owns the floor/ceil
* rounding and "is this actually a meaningful filter" comparison, so a
* controller (CategoryController, SearchController, ...) doesn't have to
* reimplement that rule itself.
*/
class PriceSliderBounds
{
public function __construct(
public readonly ?int $floor,
public readonly ?int $ceil,
public readonly bool $filtered,
) {}
}
+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,
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace Modules\Core\Catalog\DTOs;
use Illuminate\Pagination\LengthAwarePaginator;
/**
* Everything a listing page needs from one ProductService::list() call —
* the product page itself, the price slider's bounds, and the set of tags
* actually present on matching products (for a tag filter sidebar) — so a
* controller makes one service call instead of orchestrating list(),
* priceSliderBounds(), and facets('tags', ...) separately. list() still
* issues multiple Meilisearch requests internally (the product search, the
* price facet stats, the tag facet distribution — see priceSliderBounds()'s
* 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
{
/**
* @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 readonly LengthAwarePaginator $products,
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,
+90 -22
View File
@@ -2,11 +2,15 @@
namespace Modules\Core\Catalog\Services; namespace Modules\Core\Catalog\Services;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Pagination\LengthAwarePaginator;
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\DTOs\ProductListingResult;
use Modules\Core\Catalog\Enums\ProductSort;
use Modules\Core\Catalog\Support\ProductDocumentLocalizer;
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
@@ -17,39 +21,103 @@ use Lunar\Models\Product;
*/ */
class ProductSearchService class ProductSearchService
{ {
/** public function __construct(
* @return Collection<int, Product> private readonly ProductFilterBuilder $filterBuilder,
*/ private readonly ProductDocumentLocalizer $localizer,
public function search(string $query, ?string $locale = null): Collection private readonly ProductService $products,
{ ) {}
$locale ??= App::getLocale();
$defaultLocale = Language::getDefault()->code;
return Product::search($query) /**
->options([ * Returns the exact same Modules\Core\Catalog\DTOs\ProductListingResult
'attributesToSearchOn' => $this->searchableFields($locale, $defaultLocale), * ProductService::list() does — a search results page and a category
]) * listing page consume identically shaped data, one call each. The
->get(); * paginator itself carries plain, localized indexed-document arrays
* (not hydrated Product models), same as list().
*
* priceBounds/availableTags are delegated to ProductService's own
* priceSliderBounds()/availableTags() rather than reimplemented here —
* both already accept a $query param for exactly this reason (a search
* page's slider/tag sidebar should reflect only the products search
* actually matched, not the whole catalog).
*
* $filters/$sort apply the exact same semantics ProductService::list()
* uses for collection browsing (same ProductFilterBuilder, same
* ProductSort::toMeilisearchSort()) — a shopper narrowing a text search
* by price/brand/stock gets identical filter behavior to narrowing a
* category listing, since both go through the same Meilisearch `filter`
* clause underneath.
*/
public function search(
string $query,
?ProductFilters $filters = null,
?ProductSort $sort = null,
int $perPage = 24,
int $page = 1,
): ProductListingResult {
$options = [
'attributesToSearchOn' => $this->searchableFields(),
'filter' => $this->filterBuilder->build($filters),
];
if ($sort !== null) {
$options['sort'] = [$sort->toMeilisearchSort()];
}
$paginator = Product::search($query)
->options($options)
->paginateRaw(perPage: $perPage, page: $page);
$data = collect($this->localizer->hitsFrom($paginator))
->map(fn (array $product) => $this->localizer->withLocalizedFields($product))
->all();
$products = new LengthAwarePaginator(
items: $data,
total: $paginator->total(),
perPage: $paginator->perPage(),
currentPage: $paginator->currentPage(),
options: ['path' => LengthAwarePaginator::resolveCurrentPath()],
);
$priceBounds = $this->products->priceSliderBounds($filters, $filters?->minPrice, $filters?->maxPrice, $query);
$availableTags = $this->products->availableTags($filters, $query);
return new ProductListingResult($products, $priceBounds, $availableTags);
} }
/** /**
* Target the resolved locale's fields plus the default locale's fields, so a * Targets every configured store language's fields, not just the current
* product that's only ever been translated into the default language still * request locale plus the store default — a shopper browsing in Greek
* surfaces when searched in another locale, instead of becoming invisible * typing an English word (or vice versa) should still match a product
* until every product is fully translated. * whose only translation for that text happens to be in a third
* language. There's no per-request "current locale" concept in this
* method any more: which fields exist to search on is a property of the
* store's configured languages, not of who's asking.
*
* Also targets variants.options.value directly — a variant's option
* value (e.g. "Κάπτεν Γαμέρικα" on a "Name" option) is how ProductIndexer
* already indexes it (see mapVariant()), but it isn't one of Lunar's own
* attributes, so it can't come from AttributeManifest the way name/
* description do; it's a structural field of the document, added here
* directly instead. Not locale-suffixed like the attribute-manifest
* fields — option values are stored as one already-resolved string per
* variant (see ProductIndexer::translatedName()), not per-locale.
* *
* @return array<int, string> * @return array<int, string>
*/ */
private function searchableFields(string $locale, string $defaultLocale): array private function searchableFields(): array
{ {
$handles = AttributeManifest::getSearchableAttributes(Product::morphName()) $handles = AttributeManifest::getSearchableAttributes(Product::morphName())
->pluck('handle'); ->pluck('handle');
$locales = array_unique([$locale, $defaultLocale]); $locales = Language::all()->pluck('code');
return $handles $attributeFields = $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();
} }
+169 -103
View File
@@ -2,16 +2,14 @@
namespace Modules\Core\Catalog\Services; namespace Modules\Core\Catalog\Services;
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 Lunar\Base\AttributeManifest;
use Lunar\FieldTypes\TranslatedText;
use Lunar\Models\Product; use Lunar\Models\Product;
use Modules\Core\Localization\Services\LanguageCache; use Modules\Core\Catalog\DTOs\PriceSliderBounds;
use Modules\Core\Catalog\DTOs\ProductFilters; use Modules\Core\Catalog\DTOs\ProductFilters;
use Modules\Core\Catalog\DTOs\ProductListingResult;
use Modules\Core\Catalog\Enums\ProductSort; use Modules\Core\Catalog\Enums\ProductSort;
use Modules\Core\Catalog\Support\ProductDocumentLocalizer;
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
@@ -25,19 +23,34 @@ use Modules\Core\Catalog\Enums\ProductSort;
class ProductService class ProductService
{ {
public function __construct( public function __construct(
private readonly LanguageCache $languages, private readonly ProductDocumentLocalizer $localizer,
private readonly AttributeManifest $attributes, private readonly ProductFilterBuilder $filterBuilder,
) {} ) {}
/** /**
* Returns a real LengthAwarePaginator (not Scout's own paginateRaw() result - * One call for everything a listing page needs: the product page AND
* see "Meilisearch driver quirk" below) so a controller/view gets normal * the price slider's bounds — a controller used to have to call this
* pagination behaviour ($products->links(), JSON serialization, etc.) * plus priceSliderBounds() separately and glue the results together
* without ever touching the raw Meilisearch response directly. * itself; that orchestration now happens in here instead. Still issues
* two Meilisearch requests under the hood (the product search, and a
* separate price-facet-stats query — see priceSliderBounds()'s
* docblock for why they can't be merged into one without changing the
* slider's own UX), but the caller only ever makes one call.
*
* $filters->minPrice/$filters->maxPrice double as both the applied
* product filter AND the "is the slider actually narrowed" comparison
* in priceSliderBounds() — the same values, used two ways, so nothing
* new needs to be threaded through separately.
*
* The paginator itself is a real LengthAwarePaginator (not Scout's own
* paginateRaw() result - see "Meilisearch driver quirk" below) so a
* controller/view gets normal pagination behaviour ($products->links(),
* JSON serialization, etc.) without ever touching the raw Meilisearch
* response directly.
*/ */
public function list(?ProductFilters $filters = null, int $perPage = 24, int $page = 1, ?ProductSort $sort = null): LengthAwarePaginator public function list(?ProductFilters $filters = null, int $perPage = 24, int $page = 1, ?ProductSort $sort = null): ProductListingResult
{ {
$options = ['filter' => $this->buildFilter($filters)]; $options = ['filter' => $this->filterBuilder->build($filters)];
if ($sort !== null) { if ($sort !== null) {
$options['sort'] = [$sort->toMeilisearchSort()]; $options['sort'] = [$sort->toMeilisearchSort()];
@@ -47,17 +60,48 @@ class ProductService
->options($options) ->options($options)
->paginateRaw(perPage: $perPage, page: $page); ->paginateRaw(perPage: $perPage, page: $page);
$data = collect($this->hitsFrom($paginator)) $data = collect($this->localizer->hitsFrom($paginator))
->map(fn (array $product) => $this->withLocalizedFields($product)) ->map(fn (array $product) => $this->localizer->withLocalizedFields($product))
->all(); ->all();
return new LengthAwarePaginator( $products = 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);
$availableTags = $this->availableTags($filters);
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.
*
* $query defaults to '' (every product, same as list()'s own default
* text query) — same reasoning as priceRange()'s own $query: pass the
* shopper's search text here too so a search page's own tag sidebar
* reflects only the products search actually matched. Public (not
* private, unlike the rest of this listing-only orchestration) so
* ProductSearchService::search() can reuse it directly rather than
* reimplementing the same facet call a second time.
*
* @return array<int, string>
*/
public function availableTags(?ProductFilters $filters, string $query = ''): array
{
$filter = $this->filterBuilder->build($filters, exclude: ['tag']);
$tags = $this->rawFacets('tags', $filter, $query)['facetDistribution']['tags'] ?? [];
return collect($tags)->keys()->sort()->values()->all();
} }
/** /**
@@ -71,15 +115,18 @@ 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
*/ */
public function facets(string $field, ?ProductFilters $filters = null): array public function facets(string $field, ?ProductFilters $filters = null): array
{ {
return $this->rawFacets($field, $this->buildFilter($filters))['facetDistribution'][$field] ?? []; return $this->rawFacets($field, $this->filterBuilder->build($filters))['facetDistribution'][$field] ?? [];
} }
/** /**
@@ -90,12 +137,17 @@ 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): array public function priceRange(?ProductFilters $filters = null, string $query = ''): array
{ {
$filter = $this->buildFilter($filters, exclude: ['price']); $filter = $this->filterBuilder->build($filters, exclude: ['price']);
$stats = $this->rawFacets('price', $filter)['facetStats']['price'] ?? null; $stats = $this->rawFacets('price', $filter, $query)['facetStats']['price'] ?? null;
return [ return [
'min' => $stats['min'] ?? null, 'min' => $stats['min'] ?? null,
@@ -103,9 +155,36 @@ class ProductService
]; ];
} }
private function rawFacets(string $field, ?string $filter): array /**
* priceRange() rounded to whole euros (floor/ceil, so the slider's ends
* are never tighter than what's actually in range) plus whether
* $selectedMinPrice/$selectedMaxPrice actually narrow it — the same
* "floor/ceil + is this a real filter" rule CategoryController and
* SearchController each used to duplicate inline. $selectedMinPrice/
* $selectedMaxPrice are the currently-applied filter values (e.g.
* CategoryListing::$minPrice), not part of $filters itself, since
* $filters here must already exclude price the way priceRange() expects.
*/
public function priceSliderBounds(
?ProductFilters $filters,
?float $selectedMinPrice,
?float $selectedMaxPrice,
string $query = '',
): PriceSliderBounds {
$priceRange = $this->priceRange($filters, $query);
$floor = $priceRange['min'] !== null ? (int) floor($priceRange['min']) : null;
$ceil = $priceRange['max'] !== null ? (int) ceil($priceRange['max']) : null;
$filtered = ($selectedMinPrice !== null && $selectedMinPrice > ($floor ?? PHP_INT_MIN))
|| ($selectedMaxPrice !== null && $selectedMaxPrice < ($ceil ?? PHP_INT_MAX));
return new PriceSliderBounds($floor, $ceil, $filtered);
}
private function rawFacets(string $field, ?string $filter, string $query = ''): array
{ {
return Product::search('') return Product::search($query)
->options([ ->options([
'filter' => $filter, 'filter' => $filter,
'facets' => [$field], 'facets' => [$field],
@@ -133,99 +212,86 @@ class ProductService
return $this->findOneWhere("id = \"{$id}\""); return $this->findOneWhere("id = \"{$id}\"");
} }
private function findOneWhere(string $filter): ?array
{
$paginator = Product::search('')
->options(['filter' => $filter])
->paginateRaw(perPage: 1, page: 1);
$product = $this->hitsFrom($paginator)[0] ?? null;
return $product !== null ? $this->withLocalizedFields($product) : null;
}
/** /**
* Resolves every translated Product attribute's current-locale value from the * The id/price/image of every variant on a product document (from
* indexer's per-locale `{handle}_{locale}` fields (e.g. `name_el`, `name_en`, * getById()/getBySlug()'s own 'variants' array) — the base price and
* `seo_title_el`, ...) into a plain `{handle}` key, falling back to the store's * thumbnail a variant picker/swatch list needs, without a caller
* default language (LanguageCache::defaultLocale()) when the current locale * reaching into $product['variants'][n]['prices'][0]/['media'][0]
* has no translation - e.g. a product with no English copy yet still shows its * itself. Domain shaping (which price/image represents a variant),
* Greek name on /en/ rather than rendering blank. * 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.
* *
* Which handles are translated is read from AttributeManifest - the same * @param array $product A document from getById()/getBySlug().
* source Lunar's own ScoutIndexer reads when exploding a TranslatedText * @return array<int, array{id: int, price: ?float, image: ?string}>
* attribute into `{handle}_{locale}` keys at index time - rather than a fixed
* list, so a store's own custom translated attributes (e.g. `seo_title`) are
* picked up automatically with no change here. The raw per-locale keys are
* then stripped, since once resolved, callers only ever need the one that
* matched the current locale.
*
* Deliberately not config('app.locale') - App::setLocale() overwrites that
* config value on every request, so by request time it's just whatever the
* current locale already is, not a stable fallback.
*/ */
private function withLocalizedFields(array $product): array public function variantSummaries(array $product): array
{ {
$locale = App::getLocale(); return collect($product['variants'] ?? [])
$fallbackLocale = $this->languages->defaultLocale(); ->map(fn (array $variant) => [
$availableLocales = $this->languages->availableLocales(); 'id' => $variant['id'],
'price' => $variant['prices'][0]['price'] ?? null,
foreach ($this->translatedAttributeHandles() as $handle) { 'image' => $variant['media'][0]['url'] ?? null,
$product[$handle] = $product[$handle.'_'.$locale] ?? $product[$handle.'_'.$fallbackLocale] ?? null; ])
->values()
foreach ($availableLocales as $availableLocale) {
unset($product[$handle.'_'.$availableLocale]);
}
}
return $product;
}
/**
* @return array<int, string>
*/
private function translatedAttributeHandles(): array
{
return $this->attributes->getSearchableAttributes((new Product)->getMorphClass())
->filter(fn ($attribute) => $attribute->type === TranslatedText::class)
->pluck('handle')
->all(); ->all();
} }
/** /**
* For the Meilisearch driver, Scout's paginateRaw() puts the whole raw response * $limit random products, still scoped to the index's own default
* (hits, query, processingTimeMs, ...) in items(), not a plain list of hits - the * visibility (channel/status), unlike Eloquent's Product::inRandomOrder()
* actual documents are under the 'hits' key. * 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>
*/ */
private function hitsFrom(LengthAwarePaginatorContract $paginator): array public function random(int $limit): array
{ {
$rawResponse = $paginator->items(); $raw = Product::search('')
->options(['attributesToRetrieve' => ['id']])
->raw();
return collect($rawResponse['hits'] ?? [])->values()->all(); $ids = collect($raw['hits'] ?? [])->pluck('id')->shuffle()->take($limit)->values();
if ($ids->isEmpty()) {
return [];
}
// Meilisearch's `id IN [...]` doesn't preserve the given order — it's
// an unordered set filter, not a list to iterate — so the shuffle
// above would otherwise be silently undone by whatever order the
// re-fetch comes back in. Re-sort the fetched documents back into
// $ids's already-shuffled order instead of trusting the response's.
$products = collect($this->findAllWhere('id IN ['.$ids->implode(', ').']'))
->keyBy('id');
return $ids->map(fn ($id) => $products->get($id))->filter()->values()->all();
}
private function findOneWhere(string $filter): ?array
{
$products = $this->findAllWhere($filter, limit: 1);
return $products[0] ?? null;
} }
/** /**
* @param array<int, 'collectionId'|'brand'|'price'|'inStockOnly'> $exclude filter * @return array<int, array>
* 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 private function findAllWhere(string $filter, int $limit = 1000): array
{ {
if ($filters === null) { $paginator = Product::search('')
return null; ->options(['filter' => $filter])
} ->paginateRaw(perPage: $limit, page: 1);
$clauses = Collection::make([ return collect($this->localizer->hitsFrom($paginator))
'collectionId' => $filters->collectionId !== null ? "collection_ids = \"{$filters->collectionId}\"" : null, ->map(fn (array $product) => $this->localizer->withLocalizedFields($product))
'brand' => $filters->brand !== null ? 'brand = "'.addcslashes($filters->brand, '"\\').'"' : null, ->all();
'price' => Collection::make([
$filters->minPrice !== null ? "price >= {$filters->minPrice}" : null,
$filters->maxPrice !== null ? "price <= {$filters->maxPrice}" : null,
])->filter()->join(' AND ') ?: null,
'inStockOnly' => $filters->inStockOnly ? 'in_stock = true' : null,
])->except($exclude)->filter();
return $clauses->isEmpty() ? null : $clauses->join(' AND ');
} }
} }
@@ -0,0 +1,86 @@
<?php
namespace Modules\Core\Catalog\Support;
use Illuminate\Contracts\Pagination\LengthAwarePaginator as LengthAwarePaginatorContract;
use Illuminate\Support\Facades\App;
use Lunar\Base\AttributeManifest;
use Lunar\FieldTypes\TranslatedText;
use Lunar\Models\Product;
use Modules\Core\Localization\Services\LanguageCache;
/**
* Shared between Modules\Core\Catalog\Services\ProductService and
* ProductSearchService — both read the same kind of Meilisearch document
* (Modules\Core\Catalog\Services\ProductIndexer's shape) and need the
* exact same per-locale field resolution and raw-response unwrapping.
* Extracted rather than duplicated so a future fix to the localization-
* fallback logic only needs to be made once.
*/
class ProductDocumentLocalizer
{
public function __construct(
private readonly LanguageCache $languages,
private readonly AttributeManifest $attributes,
) {}
/**
* Resolves every translated Product attribute's current-locale value from the
* indexer's per-locale `{handle}_{locale}` fields (e.g. `name_el`, `name_en`,
* `seo_title_el`, ...) into a plain `{handle}` key, falling back to the store's
* default language (LanguageCache::defaultLocale()) when the current locale
* has no translation - e.g. a product with no English copy yet still shows its
* Greek name on /en/ rather than rendering blank.
*
* Which handles are translated is read from AttributeManifest - the same
* source Lunar's own ScoutIndexer reads when exploding a TranslatedText
* attribute into `{handle}_{locale}` keys at index time - rather than a fixed
* list, so a store's own custom translated attributes (e.g. `seo_title`) are
* picked up automatically with no change here. The raw per-locale keys are
* then stripped, since once resolved, callers only ever need the one that
* matched the current locale.
*
* Deliberately not config('app.locale') - App::setLocale() overwrites that
* config value on every request, so by request time it's just whatever the
* current locale already is, not a stable fallback.
*/
public function withLocalizedFields(array $product): array
{
$locale = App::getLocale();
$fallbackLocale = $this->languages->defaultLocale();
$availableLocales = $this->languages->availableLocales();
foreach ($this->translatedAttributeHandles() as $handle) {
$product[$handle] = $product[$handle.'_'.$locale] ?? $product[$handle.'_'.$fallbackLocale] ?? null;
foreach ($availableLocales as $availableLocale) {
unset($product[$handle.'_'.$availableLocale]);
}
}
return $product;
}
/**
* For the Meilisearch driver, Scout's paginateRaw() puts the whole raw response
* (hits, query, processingTimeMs, ...) in items(), not a plain list of hits - the
* actual documents are under the 'hits' key.
*/
public function hitsFrom(LengthAwarePaginatorContract $paginator): array
{
$rawResponse = $paginator->items();
return collect($rawResponse['hits'] ?? [])->values()->all();
}
/**
* @return array<int, string>
*/
private function translatedAttributeHandles(): array
{
return $this->attributes->getSearchableAttributes((new Product)->getMorphClass())
->filter(fn ($attribute) => $attribute->type === TranslatedText::class)
->pluck('handle')
->all();
}
}
@@ -0,0 +1,41 @@
<?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'|'tag'|'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,
'tag' => $filters->tag !== null ? 'tags = "'.addcslashes($filters->tag, '"\\').'"' : 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 ');
}
}
-62
View File
@@ -1,62 +0,0 @@
<?php
namespace Modules\Core\Checkout\Contracts;
use Lunar\Exceptions\FingerprintMismatchException;
use Lunar\Exceptions\Carts\CartException;
use Lunar\Models\Cart;
use Lunar\Models\Order;
/**
* 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, place the order."
*
* 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 calls Modules\Core\Checkout\Services\
* CheckoutService::placeOrder($fingerprint) itself — no driver ever calls
* Lunar\Models\Cart::createOrder() directly. This is what lets the
* storefront checkout sequence stay uniform regardless of which provider is
* active: set addresses, select shipping, hand off to whichever driver is
* configured, and the driver decides when (or whether) the order actually
* gets created. 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): Order;
}
+11 -7
View File
@@ -5,13 +5,17 @@ namespace Modules\Core\Checkout\Events;
use Lunar\Models\Order; use Lunar\Models\Order;
/** /**
* Dispatched by CheckoutService::placeOrder() the moment an Order exists — * Dispatched once an Order's placed_at is set — the handoff point between
* the handoff point between Checkout and Order (see docs/checkout.md's * Checkout/Payment and Order (see docs/checkout.md's "Three-stage
* "Three-stage lifecycle"). Checkout has no opinion about what happens * lifecycle"). Fired by Modules\Core\Order\Listeners\
* after this fires; Order's own listeners (not built yet — Order is a * ApplyResolvedPaymentStatus once it resolves a PaymentCaptured/
* named-but-unscoped concern, same status Recovery had before it existed) * PaymentAuthorized event into an actual order status change, not by
* would be what reacts to it — e.g. a confirmation email, initializing * CheckoutService directly — a draft Order can exist (via
* order status tracking. * CheckoutService::initiatePayment()) well before this fires, if payment
* resolves asynchronously (e.g. a redirect-based gateway). Checkout has no
* opinion about what happens after this fires; Order's own listeners are
* what react to it — e.g. a confirmation email, initializing order status
* tracking.
*/ */
class OrderPlaced class OrderPlaced
{ {
+88 -103
View File
@@ -10,17 +10,17 @@ use Lunar\Base\Addressable;
use Lunar\DataTypes\ShippingOption; use Lunar\DataTypes\ShippingOption;
use Lunar\Facades\ShippingManifest; use Lunar\Facades\ShippingManifest;
use Lunar\Models\Cart; use Lunar\Models\Cart;
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\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\DTOs\PaymentResult;
use Modules\Core\Payment\Models\PaymentMethod; use Modules\Core\Payment\Models\PaymentMethod;
use Modules\Core\Payment\Services\PaymentDriverRegistry;
use Modules\Core\Payment\Services\PaymentMethodCache;
/** /**
* Storefront-facing checkout operations, mirroring * Storefront-facing checkout operations, mirroring
@@ -29,9 +29,11 @@ use Modules\Core\Payment\Models\PaymentMethod;
* implementation detail. See docs/checkout.md for the full design — * implementation detail. See docs/checkout.md for the full design —
* Checkout is the middle of a three-stage lifecycle (Cart → Checkout → * Checkout is the middle of a three-stage lifecycle (Cart → Checkout →
* Order): it owns the placement moment itself (address, shipping selection, * Order): it owns the placement moment itself (address, shipping selection,
* placeOrder()) and ends the instant an Order exists. What happens to that * ensuring a draft Order exists) and hands off to Payment the instant that
* Order afterward (status transitions, fulfillment) is deliberately out of * draft exists — see initiatePayment(). What happens to that Order
* scope here — see OrderPlaced's docblock. * afterward (status transitions, fulfillment) is deliberately out of
* scope here — see docs/payments.md and Checkout\Events\OrderPlaced's
* docblock for where that now lives.
* *
* Depends on CartService for cart access rather than reaching into * Depends on CartService for cart access rather than reaching into
* Lunar\Facades\CartSession directly a second time, so Checkout stays * Lunar\Facades\CartSession directly a second time, so Checkout stays
@@ -41,6 +43,8 @@ class CheckoutService
{ {
public function __construct( public function __construct(
private readonly CartService $cart, private readonly CartService $cart,
private readonly PaymentDriverRegistry $paymentDrivers,
private readonly PaymentMethodCache $paymentMethods,
) {} ) {}
public function setShippingAddress(array|Addressable $address): Cart public function setShippingAddress(array|Addressable $address): Cart
@@ -98,60 +102,28 @@ class CheckoutService
} }
/** /**
* $fingerprint is mandatory, not optional — the caller must prove the * Every payment method currently offered to the storefront, ordered by
* cart total the shopper last saw (Cart::fingerprint()) still matches * Modules\Core\Payment\Models\PaymentMethod::position — a row is
* before an order is placed. Cart::checkFingerprint() throws Lunar's own * offered only when ALL three checks pass, each meaning something
* FingerprintMismatchException on a mismatch (a line's price changed, * different to an admin diagnosing why a method isn't showing up (see
* stock adjusted the total, another tab modified the cart) rather than * docs/payments.md):
* silently placing an order at a different total than what was shown. * 1. `enabled` — an admin turned it on.
* 2. its `driver` still resolves via PaymentDriverRegistry — the
* driver class hasn't been removed (see the `payment:sync-drivers`
* command, which sets `driver_missing_at` when this fails; a row
* with that set is excluded here regardless of `enabled`, so a
* vanished driver can never silently look "available").
* 3. the resolved driver reports Configurable::isConfigured() — its
* own runtime requirements (e.g. an API key) are met.
* *
* Not called directly by a storefront — see confirmPayment(), which is * @return Collection<int, PaymentMethod>
* the only caller and supplies the fingerprint captured in
* selectPaymentMethod(), not one the storefront has to obtain itself.
*
* No exception wrapping: Lunar\Validation\Cart\ValidateCartForOrderCreation
* (run inside Cart::createOrder()) already throws
* Lunar\Exceptions\Carts\CartException with a field-keyed MessageBag
* ($exception->errors()) for address/shipping-option validation and the
* duplicate-order guard — already the right shape for a storefront to
* render as form errors directly. FingerprintMismatchException
* propagates the same way, for the same reason.
*
* @throws FingerprintMismatchException
* @throws CartException
*/ */
public function placeOrder(string $fingerprint): Order public function getPaymentMethods(): Collection
{ {
$cart = $this->cart->currentOrCreate(); return $this->paymentMethods->all()
$cart->checkFingerprint($fingerprint); ->filter(fn (PaymentMethod $method) => $method->enabled && $method->driver_missing_at === null)
->filter(fn (PaymentMethod $method) => $this->paymentDrivers->resolve($method->driver)?->isConfigured() ?? false)
$order = $cart->createOrder(); ->values();
Event::dispatch(new OrderPlaced($order));
return $order;
}
/**
* Every payment type currently offered to the storefront — every key
* in config('lunar.payments.types') that is BOTH administratively
* enabled (Modules\Core\Payment\Models\PaymentMethod::enabled) AND
* whose registered PaymentDriver reports itself usable right now
* (PaymentDriver::isConfigured() — e.g. Stripe with no API key set is
* never offered, regardless of the enabled toggle). A type with no
* PaymentMethod row at all (never seeded) is treated as not offered,
* same as disabled — nothing here creates one; see
* InstallLunarCommand::seedPaymentMethods().
*
* @return array<string>
*/
public function getPaymentMethods(): array
{
return PaymentMethod::where('enabled', true)
->pluck('type')
->filter(fn (string $type) => $this->resolvePaymentDriver($type)?->isConfigured() ?? false)
->values()
->all();
} }
/** /**
@@ -165,31 +137,30 @@ class CheckoutService
* including any payment-type-specific adjustment (e.g. a COD * including any payment-type-specific adjustment (e.g. a COD
* surcharge), which only exists once payment_method is set and the * surcharge), which only exists once payment_method is set and the
* cart recalculates. Captured here, server-side, rather than asked of * cart recalculates. Captured here, server-side, rather than asked of
* the storefront: this is the last moment before confirmPayment() that * the storefront: this is the last moment before initiatePayment() that
* the shopper's reviewed total is known, and confirmPayment() reads it * the shopper's reviewed total is known, and initiatePayment() reads it
* back internally instead of taking a fingerprint parameter — a * back internally instead of taking a fingerprint parameter — a
* storefront should never need to know Cart::fingerprint() exists. * storefront should never need to know Cart::fingerprint() exists.
* *
* Does not itself call a PaymentDriver — selecting a method and * Does not itself call a payment driver — selecting a method and
* confirming payment against it are deliberately separate steps, same * initiating payment against it are deliberately separate steps, same
* as selecting a shipping option happens before placing the order. * as selecting a shipping option happens before placing the order.
* *
* @throws UnknownPaymentTypeException if $type isn't currently offered * @throws UnknownPaymentTypeException if $type isn't currently offered
* — see getPaymentMethods() for what that means (registered, * — see getPaymentMethods() for what that means
* administratively enabled, and its driver reports itself usable)
*/ */
public function selectPaymentMethod(string $type): Cart public function selectPaymentMethod(string $type): Cart
{ {
if (! in_array($type, $this->getPaymentMethods(), true)) { if (! $this->getPaymentMethods()->contains('type', $type)) {
throw new UnknownPaymentTypeException($type); throw new UnknownPaymentTypeException($type);
} }
$cart = $this->cart->currentOrCreate(); $cart = $this->cart->currentOrCreate();
$cart->meta = [...$cart->meta->toArray(), 'payment_method' => $type]; $cart->meta = [...($cart->meta?->toArray() ?? []), 'payment_method' => $type];
$cart->save(); $cart->save();
$cart = $cart->calculate(); $cart = $cart->calculate();
$cart->meta = [...$cart->meta->toArray(), 'checkout_fingerprint' => $cart->fingerprint()]; $cart->meta = [...($cart->meta?->toArray() ?? []), 'checkout_fingerprint' => $cart->fingerprint()];
$cart->save(); $cart->save();
Event::dispatch(new PaymentMethodSelected($cart, $type)); Event::dispatch(new PaymentMethodSelected($cart, $type));
@@ -198,51 +169,65 @@ class CheckoutService
} }
/** /**
* Resolves $type's registered PaymentDriver and calls confirm() — * The one storefront-facing "place this order and pay for it" call —
* the driver decides whether/when the order actually gets placed (see * the point where Checkout hands off to Payment. Ensures a draft
* Modules\Core\Checkout\Contracts\PaymentDriver's docblock). $data * Order exists (Cart::createOrder() — confirmed idempotent against a
* carries whatever that driver needs (Stripe's payment_intent id, a * cart's own pre-existing, not-yet-placed-at draft; see
* future redirect-based provider's callback payload). * vendor/lunarphp/core/src/Actions/Carts/CreateOrder.php), then
* resolves the payment method selected by selectPaymentMethod() and
* calls pay() or authorize() on its driver, per that method's own
* `capture_mode` column.
* *
* The fingerprint passed to the driver is the one captured by * Returns the driver's own PaymentResult UNCHANGED — this method does
* selectPaymentMethod(), not supplied by the caller — see that * not wait for or resolve anything past what pay()/authorize() itself
* method's docblock. Throws the same FingerprintMismatchException a * returns synchronously. A Pending result (an async gateway like
* caller-supplied one would if the cart's total has since changed; * Stripe requiring 3-D Secure/a redirect) is a normal, expected
* missing entirely (selectPaymentMethod() was never called for this * outcome, not an error — the caller (a storefront controller) is
* cart) is treated the same as a mismatch, not a different error. * responsible for whatever the gateway needs next.
* *
* @param array<string, mixed> $data * The draft order's own $order->total (not the Cart's) is what gets
* passed as $amount — Order::$total is Lunar's own Price-cast
* attribute, already resolving the correct Currency via the order's
* own currency_code, and is the authoritative total once the draft
* row exists.
* *
* @throws UnknownPaymentTypeException if $type isn't currently offered * $context passed to the driver is {cart_id, order_id} — the exact
* (see getPaymentMethods()) — re-checked here, not just in * keys Modules\Core\Payment\Drivers\StripePaymentDriver::
* selectPaymentMethod(), since a type could be disabled between * rememberIntent() already reads.
* selection and confirmation *
* @throws \Lunar\Exceptions\FingerprintMismatchException * Same fingerprint precondition the old placeOrder() had: mandatory,
* @throws \Lunar\Exceptions\Carts\CartException * not optional, checked before the draft is created.
*
* @param array<string, mixed> $data passed through untouched to
* the driver's pay()/authorize() — e.g. Stripe's payment_method
* token.
*
* @throws UnknownPaymentTypeException if the cart's selected
* payment_method (from selectPaymentMethod()) is no longer offered
* — re-checked here, not just at selection time, since a method
* could be disabled (or its driver removed) in between
* @throws FingerprintMismatchException
* @throws CartException
*/ */
public function confirmPayment(string $type, array $data = []): Order public function initiatePayment(string $fingerprint, array $data = []): PaymentResult
{ {
if (! in_array($type, $this->getPaymentMethods(), true)) { $cart = $this->cart->currentOrCreate();
throw new UnknownPaymentTypeException($type); $cart->checkFingerprint($fingerprint);
$type = $cart->meta['payment_method'] ?? null;
$method = $type !== null ? $this->getPaymentMethods()->firstWhere('type', $type) : null;
if ($method === null) {
throw new UnknownPaymentTypeException((string) $type);
} }
$cart = $this->cart->currentOrCreate(); $order = $cart->createOrder();
$fingerprint = $cart->meta['checkout_fingerprint'] ?? '';
return $this->resolvePaymentDriver($type)->confirm($cart, $type, $fingerprint, $data); $driver = $this->paymentDrivers->resolve($method->driver);
} $context = ['cart_id' => $cart->id, 'order_id' => $order->id];
/** return $method->capture_mode === 'authorize'
* Resolves $type's registered PaymentDriver, or null if $type has no ? $driver->authorize($type, $order->total, $data, $context)
* 'payment_driver' registered in config('lunar.payments.types.<type>') : $driver->pay($type, $order->total, $data, $context);
* 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;
} }
} }
+29 -25
View File
@@ -284,35 +284,39 @@ class InstallLunarCommand extends Command
} }
/** /**
* Per-type skip-if-exists, same idempotent convention as * A single, deliberately opinionated starter row on fresh install —
* seedStorefrontLabels() — a type already present (including one an * `PaymentMethod` is now fully admin-creatable/deletable (see
* admin has since edited via the Filament Payment Methods resource) is * docs/payments.md), so this is no longer "seed every config-defined
* left untouched. Safe to re-run after a new payment type is added to * type," it's "give a fresh store one reasonable payment method to
* config('lunar.payments.types') (e.g. installing a Stripe/Nexi * start from instead of zero." Every value here is a plain literal in
* package), which is the whole reason this isn't a one-time-only seed. * THIS command, not sourced from config or PaymentDriverRegistry — a
* driver has no business carrying opinions about what its captured
* order status should be called; that's a merchant decision.
* *
* Seeded disabled — a newly-seeded row (whether from this store's * Skip-if-exists on `type`, same idempotent convention as
* initial install, or a payment provider package installed later) * seedStorefrontLabels() — an admin who has since edited or deleted
* shouldn't go live for shoppers before staff have actually reviewed * this row (via the Filament Payment Methods resource) is left alone;
* it (real credentials configured, a fee set, etc.) and turned it on * re-running lunar:install never recreates a deleted starter row.
* via the Payment Methods resource. See CheckoutService:: *
* getPaymentMethods(), which only offers a type once both 'enabled' * Seeded disabled — shouldn't go live for shoppers before staff have
* here and its driver's own isConfigured() check pass. * actually reviewed it and turned it on via the Payment Methods
* resource. See CheckoutService::getPaymentMethods().
*/ */
private function seedPaymentMethods(): void private function seedPaymentMethods(): void
{ {
$existingTypes = PaymentMethod::pluck('type'); if (PaymentMethod::where('type', 'cash-on-delivery')->exists()) {
return;
foreach (array_keys(config('lunar.payments.types', [])) as $type) {
if ($existingTypes->contains($type)) {
continue;
}
PaymentMethod::create([
'type' => $type,
'enabled' => false,
'data' => [],
]);
} }
PaymentMethod::create([
'type' => 'cash-on-delivery',
'name' => 'Cash on Delivery',
'driver' => 'offline',
'capture_mode' => 'pay',
'captured_status' => 'payment-offline',
'position' => 0,
'enabled' => false,
'data' => [],
]);
} }
} }
+52
View File
@@ -0,0 +1,52 @@
<?php
namespace Modules\Core\Command;
use Illuminate\Console\Command;
use Modules\Core\Payment\Models\PaymentMethod;
use Modules\Core\Payment\Services\PaymentDriverRegistry;
/**
* Reconciles every Modules\Core\Payment\Models\PaymentMethod row's `driver`
* column against PaymentDriverRegistry — the registry only knows "which
* driver classes exist THIS deploy," and only at the moment something
* calls resolve(); nothing else notices a driver disappearing (a package
* removed, a custom Registry::register() call deleted) on its own. Meant
* to run unconditionally on every container start/deploy (alongside
* `migrate`), not on a schedule — "did the set of registered drivers
* change" is a deploy-time event, cheap enough to check every single time
* regardless of whether anything actually changed. See docs/payments.md.
*
* Sets/clears `driver_missing_at` — deliberately NOT the `enabled` column,
* so an admin's own manual toggle is never confused with "the driver
* vanished," and a driver that comes back in a later deploy auto-clears
* this with no admin action needed.
*/
class SyncPaymentDriversCommand extends Command
{
protected $signature = 'boboko:payment:sync-drivers';
protected $description = 'Flag PaymentMethod rows whose driver no longer resolves via the registry, and clear the flag for ones that do again';
public function handle(PaymentDriverRegistry $registry): int
{
$missing = 0;
$restored = 0;
PaymentMethod::query()->each(function (PaymentMethod $method) use ($registry, &$missing, &$restored) {
$resolves = $method->driver !== null && $registry->resolve($method->driver) !== null;
if (! $resolves && $method->driver_missing_at === null) {
$method->update(['driver_missing_at' => now()]);
$missing++;
} elseif ($resolves && $method->driver_missing_at !== null) {
$method->update(['driver_missing_at' => null]);
$restored++;
}
});
$this->components->info("Payment driver sync complete: {$missing} newly flagged, {$restored} restored.");
return self::SUCCESS;
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
namespace Modules\Core\Command;
use Illuminate\Console\Command;
use Laravel\Scout\EngineManager;
use Laravel\Scout\Engines\MeilisearchEngine;
use Lunar\Models\Product;
/**
* lunarphp/meilisearch's own `lunar:meilisearch:setup` only pushes
* filterableAttributes/sortableAttributes (see MeilisearchSetup::handle())
* — it has no notion of typo tolerance or prefix search, and Meilisearch's
* defaults for both are loose enough to produce bad matches on short Greek
* words. Confirmed via showMatchesPosition that a query for "Κάπτεν" was
* matching "κανένας" purely through prefixSearch's default 'indexingTime'
* behavior (their edit distance is far past anything typo tolerance would
* bridge) — fixed by disabling prefix search below, verified afterward with
* "Super"/"Superheroes"-style prefix probes returning no results for a
* partial word. minWordSizeForTypos is tightened defensively alongside it
* so short words in general get less typo-tolerant fuzzing, even though a
* separate short-word collision case ("Κάπτεν" vs "κάποτε", high letter
* overlap despite real edit distance) persisted after both settings were
* confirmed live and wasn't fully root-caused — treated as a known,
* narrow edge case rather than a blocker. Run this after
* `lunar:meilisearch:setup`, whenever Product's index needs
* (re)provisioning.
*
* Disabling prefix search here is a deliberate tradeoff: it also turns off
* legitimate partial-word matching (typing "car" matching "cart" before
* you finish the word) — useful for a future autocomplete/search-as-you-
* type UI. If that's built later, re-enable prefixSearch deliberately then,
* informed by real UX needs, rather than leaving it on by accident today.
*/
class TuneProductSearchCommand extends Command
{
protected $signature = 'lunar:meilisearch:tune-product-search';
protected $description = 'Tighten typo-tolerance and disable prefix search on the product search index';
public function handle(EngineManager $engineManager): void
{
/** @var MeilisearchEngine $engine */
$engine = $engineManager->createMeilisearchDriver();
$index = $engine->getIndex((new Product)->searchableAs());
$this->components->info('Updating typo tolerance for product search...');
$task = $index->updateTypoTolerance([
'minWordSizeForTypos' => [
'oneTypo' => 8,
'twoTypos' => 12,
],
]);
$engine->waitForTask($task['taskUid']);
$this->components->info('Disabling prefix search for product search...');
$task = $index->updatePrefixSearch('disabled');
$engine->waitForTask($task['taskUid']);
$this->components->info('Product search index tuned.');
}
}
+6 -1
View File
@@ -3,6 +3,7 @@
namespace Modules\Core; namespace Modules\Core;
use Lunar\Admin\Filament\Resources\OrderResource\Pages\ManageOrder; use Lunar\Admin\Filament\Resources\OrderResource\Pages\ManageOrder;
use Lunar\Admin\Filament\Resources\OrderResource\Pages\Components\OrderItemsTable;
use Filament\Contracts\Plugin; use Filament\Contracts\Plugin;
use Filament\Panel; use Filament\Panel;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
@@ -25,6 +26,9 @@ use Modules\Core\Cart\Filament\Resources\CartResource;
use Modules\Core\Catalog\Filament\Extensions\ProductOptionResourceExtension; use Modules\Core\Catalog\Filament\Extensions\ProductOptionResourceExtension;
use Modules\Core\Catalog\Filament\Extensions\ValuesRelationManagerExtension; use Modules\Core\Catalog\Filament\Extensions\ValuesRelationManagerExtension;
use Modules\Core\Localization\Filament\Resources\LanguageLineResource; use Modules\Core\Localization\Filament\Resources\LanguageLineResource;
use Modules\Core\Order\Filament\Extensions\OrderItemsTableExtension;
use Modules\Core\Order\Filament\Extensions\OrderRefundActionsExtension;
use Modules\Core\Order\Filament\Extensions\OrderTransactionsExtension;
use Modules\Core\Payment\Filament\Resources\PaymentMethodResource; use Modules\Core\Payment\Filament\Resources\PaymentMethodResource;
use Modules\Core\Review\Filament\Extensions\ProductResourceExtension; use Modules\Core\Review\Filament\Extensions\ProductResourceExtension;
use Modules\Core\Review\Models\ProductReview; use Modules\Core\Review\Models\ProductReview;
@@ -62,7 +66,8 @@ class CorePlugin implements Plugin
ValuesRelationManager::class => ValuesRelationManagerExtension::class, ValuesRelationManager::class => ValuesRelationManagerExtension::class,
ShippingMethodResource::class => ShippingMethodResourceExtension::class, ShippingMethodResource::class => ShippingMethodResourceExtension::class,
ListShippingMethod::class => ShippingMethodListExtension::class, ListShippingMethod::class => ShippingMethodListExtension::class,
ManageOrder::class => OrderViewExtension::class, ManageOrder::class => [OrderViewExtension::class, OrderRefundActionsExtension::class, OrderTransactionsExtension::class],
OrderItemsTable::class => OrderItemsTableExtension::class,
]); ]);
Product::macro('reviews', function (): HasMany { Product::macro('reviews', function (): HasMany {
@@ -38,6 +38,7 @@ class StorefrontLabels
'auth.login' => ['en' => 'Log In', 'el' => 'Σύνδεση'], 'auth.login' => ['en' => 'Log In', 'el' => 'Σύνδεση'],
'auth.logout' => ['en' => 'Log Out', 'el' => 'Αποσύνδεση'], 'auth.logout' => ['en' => 'Log Out', 'el' => 'Αποσύνδεση'],
'search.placeholder' => ['en' => 'Search products…', 'el' => 'Αναζήτηση προϊόντων…'], 'search.placeholder' => ['en' => 'Search products…', 'el' => 'Αναζήτηση προϊόντων…'],
'search.results_for' => ['en' => 'Search results for ', 'el' => 'Αποτελέσματα αναζήτησης για '],
'customer_reviews' => [ 'customer_reviews' => [
'en' => '{0} No customer reviews|{1} :count customer review|[2,*] :count customer reviews', 'en' => '{0} No customer reviews|{1} :count customer review|[2,*] :count customer reviews',
'el' => '{0} Καμία αξιολόγηση πελάτη|{1} :count αξιολόγηση πελάτη|[2,*] :count αξιολογήσεις πελατών', 'el' => '{0} Καμία αξιολόγηση πελάτη|{1} :count αξιολόγηση πελάτη|[2,*] :count αξιολογήσεις πελατών',
@@ -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,50 @@
<?php
namespace Modules\Core\Order\Filament\Extensions;
use Filament\Actions\BulkAction;
use Filament\Support\Exceptions\Halt;
use Filament\Tables\Table;
use Lunar\Admin\Support\Extending\BaseExtension;
/**
* Same fix as OrderRefundActionsExtension, applied to the order lines
* table's "bulk_refund" toolbar action (Lunar\Admin\...\OrderItemsTable::
* getBulkRefundAction()) — see that class's docblock for the underlying
* Filament bug (failureNotification()+failure()+halt() never actually
* sends the notification, because halt()'s Halt exception is caught before
* Filament reaches the code that would send it).
*/
class OrderItemsTableExtension extends BaseExtension
{
public function extendTable(Table $table): Table
{
return $table->toolbarActions(
array_map(
fn ($action) => $action instanceof BulkAction && $action->getName() === 'bulk_refund'
? $this->fixFailureNotification($action)
: $action,
$table->getToolbarActions(),
),
);
}
private function fixFailureNotification(BulkAction $action): BulkAction
{
$originalAction = $action->getActionFunction();
if ($originalAction === null) {
return $action;
}
return $action->action(function (array $arguments) use ($action, $originalAction) {
try {
return $action->evaluate($originalAction, $arguments);
} catch (Halt $exception) {
$action->sendFailureNotification();
throw $exception;
}
});
}
}
@@ -0,0 +1,199 @@
<?php
namespace Modules\Core\Order\Filament\Extensions;
use Filament\Actions\Action;
use Filament\Forms\Components\Select;
use Filament\Notifications\Notification;
use Filament\Support\Exceptions\Halt;
use Lunar\Admin\Support\Extending\ViewPageExtension;
use Lunar\Models\Transaction;
use Modules\Core\Payment\Contracts\SupportsRefunds;
use Modules\Core\Payment\Models\CoreTransaction;
use Modules\Core\Payment\Services\PaymentDriverRegistry;
use Modules\Core\Payment\Support\TransactionDriverAdapter;
use ReflectionProperty;
/**
* Fixes a real bug in Lunar's own admin panel, not anything specific to how
* boboko resolves payment drivers: ManageOrder::getRefundAction() and
* ::getCaptureAction() (vendor/lunarphp/lunar/.../ManageOrder.php) both
* report a failed refund/capture by calling, in this order:
* $action->failureNotification(...); $action->failure(); $action->halt();
* but Filament\Actions\Concerns\InteractsWithActions::callMountedAction()
* only ever calls sendFailureNotification() from a match($action->getStatus())
* block that runs AFTER the action's call() returns normally — halt() throws
* Filament\Support\Exceptions\Halt, which is caught in an earlier catch block
* that rolls back the DB transaction and returns null, never reaching that
* match block. So the notification set via failureNotification() is built
* but never sent: the admin sees the modal just close/reset with no
* indication anything happened. This was always broken in Lunar; it was
* invisible before because nothing in this codebase's Transaction::driver()
* could return a real, honest failure — see Payment\Support\
* TransactionDriverAdapter's own docblock for that history.
*
* Fix, for capture: wrap the action's own action() closure so that, on
* Halt, we call $action->sendFailureNotification() ourselves before letting
* the Halt continue propagating — everything else is untouched.
*
* Fix, for refund: same notification fix, but the action() closure is
* replaced outright (not wrapped) rather than reused, because refund also
* needs a "Refund via" driver Select added to the modal (see
* fixRefundAction()) and the actual call routed through
* Payment\Support\TransactionDriverAdapter::refundVia() instead of
* Lunar\Models\Transaction::refund() — see fixRefundAction()'s own
* docblock.
*/
class OrderRefundActionsExtension extends ViewPageExtension
{
public function headerActions(array $actions): array
{
return array_map(
fn (Action $action) => match ($action->getName()) {
'refund' => $this->fixRefundAction($action),
'capture' => $this->fixFailureNotification($action),
default => $action,
},
$actions,
);
}
/**
* Combines both refund-only changes on top of the failure-notification
* fix every action here gets: adds a "Refund via" driver Select
* (defaulting to the transaction's own driver) to the modal, and
* replaces the actual refund call with one that honours that field —
* calling Payment\Support\TransactionDriverAdapter::refundVia()
* directly (bypassing Lunar\Models\Transaction::refund(), whose fixed
* refund(int $amount, $notes = null) signature has no room for a
* driver override) whenever the admin picked a driver other than the
* transaction's own. When left at the default, behaviour is identical
* to calling $transaction->refund() — refundVia() resolves to the same
* driver either way.
*
* The Select is appended to Lunar's own schema closure (read via
* reflection — HasSchema::$schema has no public getter) rather than
* replacing it outright, so the transaction/amount/notes/confirm
* fields Lunar already built are untouched.
*/
private function fixRefundAction(Action $action): Action
{
$originalSchema = $this->readProtectedProperty($action, 'schema');
$action->schema(function (array $arguments) use ($action, $originalSchema) {
$fields = is_callable($originalSchema)
? $action->evaluate($originalSchema, $arguments)
: ($originalSchema ?? []);
return [
...$fields,
Select::make('driver')
->label('Refund via')
->options(fn () => $this->refundCapableDriverLabels())
->default(fn ($get) => $this->driverKeyForTransaction($get('transaction')))
->native(false)
->required(),
];
});
return $action->action(function (array $data, Action $action) {
$transaction = Transaction::find($data['transaction']);
if (! $transaction instanceof CoreTransaction) {
$action->failureNotification(fn () => Notification::make('refund_failure')->danger()->title('Transaction not found.'))
->sendFailureNotification();
throw new Halt;
}
$adapter = app(TransactionDriverAdapter::class);
$driverKey = $data['driver'] ?? $adapter->driverKeyFor($transaction);
$response = $adapter->refundVia($transaction, $driverKey, (int) bcmul((string) $data['amount'], (string) $transaction->order->currency->factor), $data['notes'] ?? null);
if (! $response->success) {
$action->failureNotification(
fn () => Notification::make('refund_failure')->color('danger')->title($response->message)
)->sendFailureNotification();
throw new Halt;
}
$action->success();
});
}
/**
* @return array<string, string>
*/
private function refundCapableDriverLabels(): array
{
$registry = app(PaymentDriverRegistry::class);
$labels = [];
foreach ($registry->all() as $key => $driverClass) {
if (app($driverClass) instanceof SupportsRefunds) {
$labels[$key] = $registry->label($key) ?? $key;
}
}
return $labels;
}
private function driverKeyForTransaction(mixed $transactionId): ?string
{
if (blank($transactionId)) {
return null;
}
$transaction = Transaction::find($transactionId);
if (! $transaction instanceof CoreTransaction) {
return null;
}
return app(TransactionDriverAdapter::class)->driverKeyFor($transaction);
}
private function readProtectedProperty(object $object, string $property): mixed
{
$reflected = new ReflectionProperty($object, $property);
$reflected->setAccessible(true);
return $reflected->getValue($object);
}
/**
* Wraps the action's own configured action() closure so that, if it
* halts (Lunar's closures throw via $action->halt() to signal failure —
* see this class's own docblock for why that alone never sends the
* notification queued via failureNotification()), we send that
* notification ourselves before letting the Halt continue propagating
* (still needed — it's what stops callMountedAction() from treating
* this as a success and closing the modal/committing the DB transaction).
*
* $this->evaluate() (not a plain call) matches exactly how Action::call()
* itself invokes the closure — Lunar's closures type-hint $data/$record/
* $action and rely on Filament's own container-style parameter
* resolution, not positional arguments.
*/
private function fixFailureNotification(Action $action): Action
{
$originalAction = $action->getActionFunction();
if ($originalAction === null) {
return $action;
}
return $action->action(function (array $arguments) use ($action, $originalAction) {
try {
return $action->evaluate($originalAction, $arguments);
} catch (Halt $exception) {
$action->sendFailureNotification();
throw $exception;
}
});
}
}
@@ -0,0 +1,33 @@
<?php
namespace Modules\Core\Order\Filament\Extensions;
use Filament\Infolists\Components\RepeatableEntry;
use Lunar\Admin\Support\Extending\ViewPageExtension;
use Modules\Core\Order\Filament\Infolists\TransactionEntry;
/**
* Swaps Lunar\Admin\Support\Infolists\Components\Transaction for our own
* TransactionEntry in the order page's transactions list — same component,
* different Blade view, so a Transaction.meta['notes'] value (written by
* a manual/attested driver like Payment\Drivers\BankTransferPaymentDriver)
* actually renders somewhere, instead of only the notes column Lunar's own
* view reads (see TransactionEntry's own docblock for why that column is
* usually empty for a successful manual payment/refund).
*
* Uses the extendTransactionsRepeatableEntry hook ManageOrder's own
* DisplaysTransactions trait already calls
* (getTransactionsRepeatableEntry() → callStaticLunarHook(
* 'extendTransactionsRepeatableEntry', ...)) — a class/component swap via
* a Lunar-provided hook, the same category of extension already used
* throughout CorePlugin, not a Blade view-path override.
*/
class OrderTransactionsExtension extends ViewPageExtension
{
public function extendTransactionsRepeatableEntry(RepeatableEntry $entry): RepeatableEntry
{
return $entry->schema([
TransactionEntry::make('transaction_detail'),
]);
}
}
@@ -0,0 +1,30 @@
<?php
namespace Modules\Core\Order\Filament\Infolists;
use Lunar\Admin\Support\Infolists\Components\Transaction as LunarTransactionEntry;
/**
* Same component as Lunar's own Transaction infolist entry — only the
* Blade view differs, to also show Transaction.meta['notes'] (what
* Payment\Drivers\BankTransferPaymentDriver and any other manual/attested
* driver write a staff-entered note into — see that driver's own
* docblock) when the notes column itself is empty. The notes column is
* populated by Order\Services\TransactionRecorder from
* PaymentResult::$failureReason, which is only ever set on a FAILED
* result — a successful manual payment/refund's note would otherwise be
* recorded (Transaction.meta) but never shown anywhere in the admin
* panel, since Lunar's own view only ever reads the notes column.
*
* Registered in place of Lunar's own Transaction component via
* Order\Filament\Extensions\OrderTransactionsExtension's
* extendTransactionsRepeatableEntry() hook (see that class), not a
* view-path override — this is the same "swap the concrete
* class/component" pattern already used throughout CorePlugin
* (LunarPanel::extensions()), rather than shadowing Lunar's Blade file
* from underneath it.
*/
class TransactionEntry extends LunarTransactionEntry
{
protected string $view = 'core::order.infolists.transaction';
}
@@ -0,0 +1,112 @@
<?php
namespace Modules\Core\Order\Listeners;
use Illuminate\Support\Facades\Event;
use Lunar\Models\Order;
use Modules\Core\Checkout\Events\OrderPlaced;
use Modules\Core\Payment\Events\PaymentAuthorized;
use Modules\Core\Payment\Events\PaymentCaptured;
use Modules\Core\Payment\Events\PaymentRefunded;
use Modules\Core\Payment\Models\PaymentMethod;
use Modules\Core\Payment\Services\PaymentMethodCache;
/**
* The only place an Order's status column is written in reaction to a
* payment outcome. Registered against PaymentCaptured, PaymentAuthorized,
* AND PaymentRefunded (see OrderServiceProvider) — same handler for all
* three, differing only in which PaymentMethod column decides the
* resulting status and, for a refund, which PaymentMethod row that even
* is (see resolvePaymentMethod()).
*
* Reads $event->context['order_id'] to find which Order this outcome
* belongs to — Payment has no concept of an Order, so this is the one
* place that context key gets consumed on the Order side (Payment's own
* StripePaymentDriver reads $context['order_id'] independently, for its
* own unrelated correlation need — see that class's rememberIntent()).
*
* Loads and saves the model (not a bulk ::whereKey()->update()) so
* Order::observe()'s updated() hook fires and OrderStatusUpdated goes out
* the same as any other status write — see that event's own docblock for
* why it's meant to fire "regardless of what wrote it."
*
* Dispatches Checkout\Events\OrderPlaced itself, once placed_at is set —
* see that event's own docblock for why this, not CheckoutService, is now
* the dispatch point. Never fires from the PaymentRefunded path — a
* refund can only ever happen after an order was already placed.
*
* Deliberately does NOT react to PaymentVoided — see PaymentMethod's own
* docblock for why there's no void_status column at all yet.
*/
class ApplyResolvedPaymentStatus
{
public function __construct(
private readonly PaymentMethodCache $paymentMethods,
) {}
public function handle(PaymentCaptured|PaymentAuthorized|PaymentRefunded $event): void
{
$orderId = $event->context['order_id'] ?? null;
if ($orderId === null) {
return;
}
$order = Order::findOrFail($orderId);
$method = $this->resolvePaymentMethod($event, $order);
$column = match (true) {
$event instanceof PaymentCaptured => 'captured_status',
$event instanceof PaymentAuthorized => 'authorized_status',
$event instanceof PaymentRefunded => 'refunded_status',
};
$status = $method?->{$column};
if ($status === null) {
return;
}
$wasPlaced = ! blank($order->placed_at);
$order->update([
'status' => $status,
'placed_at' => $order->placed_at ?? now(),
]);
if (! $wasPlaced && ! $event instanceof PaymentRefunded) {
Event::dispatch(new OrderPlaced($order));
}
}
/**
* PaymentCaptured/PaymentAuthorized carry $event->type as the
* PaymentMethod.type that was actually charged — a direct lookup.
*
* PaymentRefunded's $event->type is the REFUND driver's own registry
* key (e.g. 'bank-transfer' — see BankTransferPaymentDriver::refund()),
* which may not correspond to any PaymentMethod row at all when the
* admin refunded through a different driver than the one that took
* the original payment (Payment\Support\TransactionDriverAdapter::
* refundVia()). refunded_status is a business decision about the
* ORIGINAL payment method, not the refund mechanism, so this instead
* finds the order's earliest successful capture/intent transaction —
* the actual payment the refund is reversing — and resolves that
* transaction's own driver (a real PaymentMethod.type) instead.
*/
private function resolvePaymentMethod(PaymentCaptured|PaymentAuthorized|PaymentRefunded $event, Order $order): ?PaymentMethod
{
if (! $event instanceof PaymentRefunded) {
return $this->paymentMethods->all()->firstWhere('type', $event->type);
}
$originalType = $order->transactions()
->whereIn('type', ['capture', 'intent'])
->where('success', true)
->oldest('created_at')
->value('driver');
return $originalType !== null
? $this->paymentMethods->all()->firstWhere('type', $originalType)
: null;
}
}
@@ -0,0 +1,57 @@
<?php
namespace Modules\Core\Order\Listeners;
use Lunar\Models\Order;
use Modules\Core\Order\Services\TransactionRecorder;
use Modules\Core\Payment\Events\PaymentAuthorized;
use Modules\Core\Payment\Events\PaymentCaptured;
use Modules\Core\Payment\Events\PaymentRefunded;
use Modules\Core\Payment\Events\PaymentVoided;
/**
* Writes the Transaction row for a successful payment outcome — the
* "record what happened" half of reacting to Payment's events, separate
* from Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus's "update
* the order's status" half. Both listen to the same events for the same
* reason: two independent reactions to one payment outcome, neither
* calling the other (see docs/payments.md).
*
* Only registered against the SUCCESS events (PaymentCaptured,
* PaymentAuthorized, PaymentVoided, PaymentRefunded) — a Failed event
* never reaches here, since a failed attempt moved no money and settled
* nothing worth auditing as a Transaction row (see OrderServiceProvider's
* registration and docs/payments.md's "Explicitly out of scope" section
* on why no Failed-side Order reaction exists at all).
*
* Same defensive $context['order_id'] ?? null early-return as
* ApplyResolvedPaymentStatus — $context is caller-supplied and optional,
* and this listener must not crash for a future non-Checkout caller of
* pay()/authorize() with no order_id in its context.
*/
class RecordPaymentTransaction
{
public function __construct(
private readonly TransactionRecorder $transactions,
) {}
public function handle(PaymentCaptured|PaymentAuthorized|PaymentVoided|PaymentRefunded $event): void
{
$orderId = $event->context['order_id'] ?? null;
if ($orderId === null) {
return;
}
$order = Order::findOrFail($orderId);
$type = match ($event::class) {
PaymentAuthorized::class => 'intent',
PaymentCaptured::class => 'capture',
PaymentRefunded::class => 'refund',
PaymentVoided::class => 'void',
};
$this->transactions->record($order, $type, $event->type, $event->result);
}
}
@@ -0,0 +1,55 @@
<?php
namespace Modules\Core\Order\Services;
use Lunar\Models\Order;
use Lunar\Models\Transaction;
use Modules\Core\Payment\DTOs\PaymentResult;
use Modules\Core\Payment\Enums\PaymentResultStatus;
/**
* Writes the Transaction row a Payment operation's PaymentResult becomes —
* the one place that translates Payment's gateway-agnostic result into
* Lunar's own transactions table, in the same shape lunarphp/stripe's own
* StoreCharges already writes (type, success, amount, reference, driver).
* Lives in Order, not Payment — Transaction.order_id is required, and
* Payment never writes to another module's models (see docs/payments.md);
* this is the "read the event, do the write" half of that boundary, same
* shape as Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus.
*
* Kept as its own class (not inlined into the listener that calls it) so a
* future admin action (a manually-triggered capture/refund from Filament)
* can write a row the same way, without going through an event at all.
*/
class TransactionRecorder
{
/**
* $type is Lunar's own transaction type string — 'intent' (an
* authorize()-produced hold), 'capture' (settled funds, whether via
* pay() directly or capture() settling a prior intent), 'refund',
* 'void' is NOT one of Lunar's three built-in types (Order::
* paymentStatus() only ever reads 'intent'/'capture'/'refund' — see
* Modules\Core\Order\Support\OrderStatus::payment()) — a void never
* moved money, so it's still recorded for audit but $success reflects
* whether the RELEASE succeeded, not a captured amount.
*
* $driver is the payment type key (e.g. 'stripe', 'cash-on-delivery'),
* not a class name — matches the $type PaymentCaptured/etc. events
* themselves carry, and what Transaction.driver already means
* elsewhere in this codebase (see the old, now-removed
* TransactionRecorder this replaces).
*/
public function record(Order $order, string $type, string $driver, PaymentResult $result): Transaction
{
return $order->transactions()->create([
'success' => $result->status === PaymentResultStatus::Succeeded,
'type' => $type,
'driver' => $driver,
'amount' => $result->amount->value,
'reference' => $result->reference,
'status' => $result->status->name,
'notes' => $result->failureReason,
'meta' => $result->meta,
]);
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace Modules\Core\Payment\Contracts;
/**
* Every driver implements this, orthogonal to which payment operations
* (SupportsPay, SupportsAuthorization, ...) it supports — whether a driver
* can actually be used right now is a separate question from what it's
* capable of when it can be. An offline driver has no external dependency
* to be missing and always returns true; a gateway driver checks its own
* credentials/API key.
*/
interface Configurable
{
/**
* Independent of any admin-facing enabled/disabled toggle a caller
* might also apply on top — this is only about whether the driver
* itself is usable right now (e.g. Stripe with no API key configured
* is never usable, regardless of any such toggle).
*/
public function isConfigured(): bool;
}
@@ -0,0 +1,50 @@
<?php
namespace Modules\Core\Payment\Contracts;
use Modules\Core\Payment\DTOs\PaymentResult;
/**
* The async counterpart to SupportsPay::pay()/SupportsAuthorization::
* authorize() — implemented only by a driver whose gateway can't resolve
* one of those synchronously (a redirect the shopper completes elsewhere,
* a webhook that arrives later). A driver whose pay()/authorize() always
* returns a terminal PaymentResult (Succeeded/Failed) in the same call
* never implements this — there is nothing left to call back.
*
* Resolves into the SAME events the original pay()/authorize() call would
* have produced had it resolved synchronously — PaymentCaptured/
* PaymentCaptureFailed for a pending pay(), PaymentAuthorized/
* PaymentAuthorizationFailed for a pending authorize(). Which pair
* applies is up to the driver to track (e.g. against whatever it stored
* when the original call returned Pending), not something this method's
* signature can express generically.
*/
interface HandlesPaymentCallback
{
/**
* $reference is the gateway's own identifier for the pending attempt
* (the same value the original pay()/authorize() call returned via
* PaymentResult::$reference) — how the driver finds which attempt
* this callback belongs to.
*
* $data carries whatever the callback/webhook payload contains
* (Stripe: ['payment_intent' => $id], a redirect-based provider: its
* query params or POST body) — passed explicitly by the caller rather
* than the driver reaching into the global request(), so this works
* the same whether it's called from a synchronous HTTP request or an
* async webhook job with no active request at all.
*
* $context is opaque to the driver, carried through untouched into
* whichever Payment event this callback produces — see SupportsPay::
* pay()'s own $context param for the full reasoning. A driver that
* needs the ORIGINAL context from the pay()/authorize() call (a
* webhook's own payload carries none of its own) must have persisted
* it itself when that call returned Pending — Payment provides no
* storage for this.
*
* @param array<string, mixed> $data
* @param array<string, mixed> $context
*/
public function handleCallback(string $reference, array $data, array $context = []): PaymentResult;
}
@@ -0,0 +1,37 @@
<?php
namespace Modules\Core\Payment\Contracts;
use Lunar\DataTypes\Price;
use Modules\Core\Payment\DTOs\PaymentResult;
/**
* A driver's hold-only operation (Mastercard's "Authorize", Nexi's
* ActionType::PREAUTH(), Stripe's capture_method=manual) — places a hold
* on the customer's payment method without moving any funds. Only a
* driver that also implements SupportsCaptures/SupportsVoids can do
* anything with the resulting hold afterward; implementing this alone
* with neither of those would leave the hold to simply expire
* (typically ~7 days, gateway-dependent) with no way to settle or release
* it early.
*
* A driver capable of both authorize-then-settle AND an atomic charge
* (most card gateways) implements this alongside SupportsPay — which one
* gets called for a given payment attempt is the CALLER's choice (a
* policy decision), not something this driver decides for itself.
*
* Dispatches Modules\Core\Payment\Events\PaymentAuthorized or
* PaymentAuthorizationFailed based on the returned PaymentResult's status,
* unless $result->status is Pending — see SupportsPay's docblock for the
* same async-resolution note.
*/
interface SupportsAuthorization
{
/**
* Same $type/$amount/$data/$context reasoning as SupportsPay::pay().
*
* @param array<string, mixed> $data
* @param array<string, mixed> $context
*/
public function authorize(string $type, Price $amount, array $data = [], array $context = []): PaymentResult;
}
@@ -0,0 +1,39 @@
<?php
namespace Modules\Core\Payment\Contracts;
use Lunar\DataTypes\Price;
use Modules\Core\Payment\DTOs\PaymentResult;
/**
* Settles a PRIOR SupportsAuthorization::authorize() hold — only ever
* valid against a reference that call (or a HandlesPaymentCallback
* resolving it) produced, never called standalone. A driver with no
* authorize-then-settle model at all (most redirect/wallet gateways, any
* offline driver) never implements this — it settles everything through
* SupportsPay::pay() in one step instead.
*
* Dispatches Modules\Core\Payment\Events\PaymentCaptured or
* PaymentCaptureFailed — the same terminal events SupportsPay::pay()
* produces, since "money has been captured" is the same business fact
* regardless of which path reached it.
*/
interface SupportsCaptures
{
/**
* $reference is the identifier SupportsAuthorization::authorize()
* returned (PaymentResult::$reference) for the hold being settled.
*
* $amount is Lunar's own Price (never a gateway's own minor-unit
* scale — see PaymentResult's docblock), and lets a driver capture
* less than the full authorized amount (e.g. shipping less than
* ordered) — up to the driver/gateway whether a partial capture also
* releases the remainder or leaves it capturable again later
* (multicapture-style gateways). Required explicitly, not derived by
* the driver from a live gateway lookup — the caller (whatever placed
* the original authorize() call) already knows it.
*
* @param array<string, mixed> $context
*/
public function capture(string $reference, Price $amount, array $context = []): PaymentResult;
}
+52
View File
@@ -0,0 +1,52 @@
<?php
namespace Modules\Core\Payment\Contracts;
use Lunar\DataTypes\Price;
use Modules\Core\Payment\DTOs\PaymentResult;
/**
* A driver's atomic charge — authorize and capture in one gateway call
* (Mastercard's own "Pay" operation, Nexi's ActionType::PAY(), Stripe's
* capture_method=automatic, or an offline driver with no gateway at all).
* Distinct from SupportsAuthorization: a driver that only ever settles in
* one step implements this and nothing else — there is no separate hold
* to later capture() or void().
*
* Dispatches Modules\Core\Payment\Events\PaymentCaptured or
* PaymentCaptureFailed based on the returned PaymentResult's status,
* unless $result->status is Pending (an async gateway that hasn't
* resolved yet — see HandlesPaymentCallback for how that gets resolved
* later, from a separate call this method's return value does not wait
* on).
*/
interface SupportsPay
{
/**
* $type is the payment type key being charged (e.g. 'cash-on-delivery',
* '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.
*
* $amount is required, not optional data a caller might omit — there
* is no way to process a payment without knowing what to charge.
* Lunar's own Price (bundling its own currency) — the same money
* representation every other Payment contract method takes/returns,
* see PaymentResult's own docblock.
*
* $data carries whatever ELSE the gateway needs (customer details, a
* payment method token) — 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 — carried through untouched into
* whichever Payment event this call (or a later handleCallback()
* resolving it) produces, so the caller can correlate the result back
* to whatever it needs, without Payment ever needing to know what
* that is.
*
* @param array<string, mixed> $data
* @param array<string, mixed> $context
*/
public function pay(string $type, Price $amount, array $data = [], array $context = []): PaymentResult;
}
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace Modules\Core\Payment\Contracts;
use Lunar\DataTypes\Price;
use Modules\Core\Payment\DTOs\PaymentResult;
/**
* Reverses settled funds — independent of SupportsCaptures/SupportsVoids:
* a driver that only ever settles via SupportsPay::pay() (no separate
* authorize step) can still implement this, since a refund targets money
* already taken regardless of how it got taken. A driver implements this
* whenever its gateway exposes any refund capability at all, whether or
* not it also supports authorize-then-capture.
*
* Dispatches Modules\Core\Payment\Events\PaymentRefunded or
* PaymentRefundFailed.
*/
interface SupportsRefunds
{
/**
* $reference is the identifier the original SupportsPay::pay() or
* SupportsCaptures::capture() call returned for the settled funds
* being refunded.
*
* $amount is Lunar's own Price (never a gateway's own minor-unit
* scale — see PaymentResult's docblock), allowing a partial refund; a
* gateway may allow multiple partial refunds against one settlement,
* up to its own total. Required explicitly, same reasoning as
* SupportsCaptures::capture()'s own $amount.
*
* @param array<string, mixed> $context
*/
public function refund(string $reference, Price $amount, array $context = []): PaymentResult;
}
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace Modules\Core\Payment\Contracts;
use Lunar\DataTypes\Price;
use Modules\Core\Payment\DTOs\PaymentResult;
/**
* Cancels a PRIOR SupportsAuthorization::authorize() hold WITHOUT
* settling it — the "actually, never mind" exit SupportsCaptures::capture()
* doesn't take. No funds ever moved, so this is not a refund: there is
* nothing to give back, only a hold to release early (rather than letting
* it simply expire on its own).
*
* Dispatches Modules\Core\Payment\Events\PaymentVoided or
* PaymentVoidFailed.
*/
interface SupportsVoids
{
/**
* $reference is the identifier SupportsAuthorization::authorize()
* returned for the hold being released.
*
* $amount is the authorized amount being released — Lunar's own
* Price, same as every other Payment contract method (see
* PaymentResult's own docblock). Required explicitly: the caller
* (whatever placed the original authorize() call) already knows it,
* same reasoning as SupportsCaptures::capture()'s own $amount — a
* driver shouldn't need a live gateway lookup just to know what it's
* releasing.
*
* @param array<string, mixed> $context
*/
public function void(string $reference, Price $amount, array $context = []): PaymentResult;
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace Modules\Core\Payment\DTOs;
use Modules\Core\Payment\Enums\PaymentContinuationType;
/**
* What a caller does next with a Pending PaymentResult, gateway-agnostic —
* see PaymentContinuationType for the two shapes. Deliberately minimal:
* this is NOT a return to the deleted PaymentInitiation DTO (which also
* carried mode/reference/meta) — reference already lives on PaymentResult
* itself, and mode is now this DTO's own $type.
*/
final class PaymentContinuation
{
public function __construct(
public readonly PaymentContinuationType $type,
public readonly string $value,
) {}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace Modules\Core\Payment\DTOs;
use Lunar\DataTypes\Price;
use Modules\Core\Payment\Enums\PaymentResultStatus;
/**
* The one shape every Payment operation (pay, authorize, capture, void,
* refund, handleCallback) returns, regardless of driver — a caller never
* writes gateway-specific branching to read the outcome.
*
* Deliberately not one-size-fits-all in richness underneath: a gateway's
* own response can be as sparse as Nexi's capture (just an operation id +
* timestamp, no echoed amount or status) or as rich as Stripe's
* PaymentIntent (status, amounts, decline classification, full error
* detail). $status/$reference/$amount are the only fields every driver can
* always populate — $amount from what WE requested, not necessarily
* echoed by the gateway. Everything else is best-effort normalization;
* $raw is the unconditional escape hatch for genuine audit fidelity
* (the untouched gateway response), so nothing is ever lost even when a
* gateway has no field to normalize into $failureReason/$retriable.
*/
final class PaymentResult
{
/**
* @param $amount Lunar's own money type (Lunar\DataTypes\Price —
* integer minor units bundled with its Currency), the SAME
* representation every contract method takes/returns — never a
* gateway's own minor-unit scale. Each driver converts at its own
* boundary (e.g. StripeManager::toStripeAmount()/fromStripeAmount())
* before calling out to, or after reading back from, its gateway —
* Payment itself only ever speaks Lunar's Price.
* @param $failureReason a human-readable reason, only meaningful
* when $status is Failed — the driver's own normalization of
* whatever the gateway called it (Stripe's decline_code message,
* Nexi's ErrorsInner::$description, ...).
* @param $retriable whether the caller should offer "try again" with
* the SAME payment method, vs. "use a different one" — real,
* gateway-native distinction on Stripe (decline_code soft/hard) and
* Mastercard (merchantAdviceCode / scheme soft-decline codes), but
* Nexi's OperationResult has no such signal at all. Defaults to
* false (assume not safely retriable) rather than guessing when a
* driver's gateway has no such classification.
* @param $raw the untouched gateway response body — always
* populated, even when the gateway's own fields were too sparse to
* normalize into anything above.
* @param $meta driver-specific extras that don't fit the normalized
* fields above (e.g. a card's last four digits).
* @param $continuation only meaningful when $status is Pending —
* what the caller does next (a redirect URL, a client secret for
* frontend JS), gateway-agnostic. Null for every other status, and
* for any driver whose pay()/authorize() never returns Pending
* (e.g. OfflinePaymentDriver).
*/
public function __construct(
public readonly PaymentResultStatus $status,
public readonly string $reference,
public readonly Price $amount,
public readonly ?string $failureReason = null,
public readonly bool $retriable = false,
public readonly array $raw = [],
public readonly array $meta = [],
public readonly ?PaymentContinuation $continuation = null,
) {}
}
@@ -0,0 +1,74 @@
<?php
namespace Modules\Core\Payment\Drivers;
use Illuminate\Support\Str;
use Lunar\DataTypes\Price;
use Modules\Core\Payment\Contracts\Configurable;
use Modules\Core\Payment\Contracts\SupportsPay;
use Modules\Core\Payment\Contracts\SupportsRefunds;
use Modules\Core\Payment\DTOs\PaymentResult;
use Modules\Core\Payment\Enums\PaymentResultStatus;
use Modules\Core\Payment\Events\PaymentCaptured;
use Modules\Core\Payment\Events\PaymentRefunded;
/**
* Manual/attested, same trust model as OfflinePaymentDriver — there is no
* bank API to call, so both pay() and refund() decide success immediately
* on a staff member's say-so (they've already sent/received the wire
* outside the system). Distinct from OfflinePaymentDriver in intent: this
* exists so a payment taken through a DIFFERENT method (e.g.
* cash-on-delivery) can still be REFUNDED via bank transfer — an admin
* chooses this driver explicitly in the refund action, independent of
* which driver the original payment went through (see
* Payment\Support\TransactionDriverAdapter::refundVia() and
* Order\Filament\Extensions\OrderRefundActionsExtension). pay() exists so
* the same driver also covers receiving a payment by bank transfer, but
* the admin UI for that (bank reference, notes, proof-of-transfer upload)
* is deliberately not built yet — see the follow-up work tracked from this
* session; pay() itself is complete and usable via the registry today.
*
* $reference is generated here for the same reason as OfflinePaymentDriver's
* pay(): there is no gateway to hand one back. 'notes' in $context (not
* $data — refund() has no $data parameter) is folded into
* PaymentResult::$meta, which Order\Services\TransactionRecorder::record()
* already writes straight into Transaction.meta with no extra plumbing.
*/
class BankTransferPaymentDriver implements Configurable, SupportsPay, SupportsRefunds
{
/**
* Always true — no external dependency to be missing.
*/
public function isConfigured(): bool
{
return true;
}
public function pay(string $type, Price $amount, array $data = [], array $context = []): PaymentResult
{
$result = new PaymentResult(
status: PaymentResultStatus::Succeeded,
reference: 'bank-transfer-'.Str::uuid(),
amount: $amount,
meta: array_filter(['notes' => $data['notes'] ?? null]),
);
PaymentCaptured::dispatch($type, $result, $context);
return $result;
}
public function refund(string $reference, Price $amount, array $context = []): PaymentResult
{
$result = new PaymentResult(
status: PaymentResultStatus::Succeeded,
reference: 'bank-transfer-'.Str::uuid(),
amount: $amount,
meta: array_filter(['notes' => $context['notes'] ?? null]),
);
PaymentRefunded::dispatch('bank-transfer', $result, $context);
return $result;
}
}
+26 -34
View File
@@ -2,35 +2,28 @@
namespace Modules\Core\Payment\Drivers; namespace Modules\Core\Payment\Drivers;
use Lunar\Exceptions\Carts\CartException; use Illuminate\Support\Str;
use Lunar\Exceptions\DisallowMultipleCartOrdersException; use Lunar\DataTypes\Price;
use Lunar\Exceptions\FingerprintMismatchException; use Modules\Core\Payment\Contracts\Configurable;
use Lunar\Models\Cart; use Modules\Core\Payment\Contracts\SupportsPay;
use Lunar\Models\Order; use Modules\Core\Payment\DTOs\PaymentResult;
use Modules\Core\Checkout\Contracts\PaymentDriver; use Modules\Core\Payment\Enums\PaymentResultStatus;
use Modules\Core\Checkout\Services\CheckoutService; use Modules\Core\Payment\Events\PaymentCaptured;
/** /**
* 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. There is no separate hold-then-settle model
* the order immediately, same as Lunar's own OfflinePayment would, but * (SupportsAuthorization/SupportsCaptures/SupportsVoids) and no async
* through CheckoutService::placeOrder() so it goes through the same * resolution (HandlesPaymentCallback) — pay() decides success immediately
* fingerprint check every other driver does. $data is unused: nothing about * and dispatches PaymentCaptured before returning.
* this confirmation depends on gateway-specific payload.
* *
* Sets the order status to config("lunar.payments.types.{$type}.authorized") * $reference is generated here (not supplied by a gateway, since there is
* afterward, using the type actually confirmed — not a hardcoded key — * none) purely so PaymentCaptured, and anything downstream keying on it,
* since this one driver is shared across multiple types. * have something to identify this attempt by.
* placeOrder() itself leaves the order at Lunar's configured draft_status,
* same as every driver is responsible for moving it on from.
*/ */
class OfflinePaymentDriver implements PaymentDriver class OfflinePaymentDriver implements Configurable, SupportsPay
{ {
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,18 @@ class OfflinePaymentDriver implements PaymentDriver
return true; return true;
} }
/** public function pay(string $type, Price $amount, array $data = [], array $context = []): PaymentResult
* @throws FingerprintMismatchException
* @throws CartException
* @throws DisallowMultipleCartOrdersException
*/
public function confirm(Cart $cart, string $type, string $fingerprint, array $data): Order
{ {
$order = $this->checkout->placeOrder($fingerprint); $reference = 'offline-'.Str::uuid();
$order->update([ $result = new PaymentResult(
'status' => config("lunar.payments.types.{$type}.authorized", $order->status), status: PaymentResultStatus::Succeeded,
]); reference: $reference,
amount: $amount,
);
return $order->refresh(); PaymentCaptured::dispatch($type, $result, $context);
return $result;
} }
} }
+340 -68
View File
@@ -2,43 +2,69 @@
namespace Modules\Core\Payment\Drivers; namespace Modules\Core\Payment\Drivers;
use Lunar\Exceptions\FingerprintMismatchException; use Lunar\DataTypes\Price;
use Lunar\Exceptions\Carts\CartException; use Lunar\Models\Currency;
use Lunar\Exceptions\DisallowMultipleCartOrdersException;
use Lunar\Models\Cart;
use Lunar\Models\Order;
use Lunar\Stripe\Actions\UpdateOrderFromIntent;
use Lunar\Stripe\Facades\Stripe; use Lunar\Stripe\Facades\Stripe;
use Lunar\Stripe\Managers\StripeManager;
use Lunar\Stripe\Models\StripePaymentIntent; use Lunar\Stripe\Models\StripePaymentIntent;
use Modules\Core\Checkout\Contracts\PaymentDriver; use Modules\Core\Payment\Contracts\Configurable;
use Modules\Core\Checkout\Services\CheckoutService; use Modules\Core\Payment\Contracts\HandlesPaymentCallback;
use Modules\Core\Payment\Exceptions\PaymentNotConfirmedException; use Modules\Core\Payment\Contracts\SupportsAuthorization;
use Modules\Core\Payment\Contracts\SupportsCaptures;
use Modules\Core\Payment\Contracts\SupportsPay;
use Modules\Core\Payment\Contracts\SupportsRefunds;
use Modules\Core\Payment\Contracts\SupportsVoids;
use Modules\Core\Payment\DTOs\PaymentContinuation;
use Modules\Core\Payment\DTOs\PaymentResult;
use Modules\Core\Payment\Enums\PaymentContinuationType;
use Modules\Core\Payment\Enums\PaymentResultStatus;
use Modules\Core\Payment\Events\PaymentAuthorizationFailed;
use Modules\Core\Payment\Events\PaymentAuthorized;
use Modules\Core\Payment\Events\PaymentCaptureFailed;
use Modules\Core\Payment\Events\PaymentCaptured;
use Modules\Core\Payment\Events\PaymentRefundFailed;
use Modules\Core\Payment\Events\PaymentRefunded;
use Modules\Core\Payment\Events\PaymentVoidFailed;
use Modules\Core\Payment\Events\PaymentVoided;
use Stripe\Exception\ApiErrorException;
use Stripe\PaymentIntent; use Stripe\PaymentIntent;
/** /**
* Wraps Lunar\Stripe\StripePaymentType::authorize() to satisfy * Talks to Stripe's PaymentIntent API directly — deliberately NOT via
* Modules\Core\Checkout\Contracts\PaymentDriver — calls * Lunar\Stripe\Facades\Stripe::createIntent()/fetchOrCreateIntent(), which
* CheckoutService::placeOrder($fingerprint) at the moment Stripe confirms * take a Lunar\Models\Cart and derive amount/currency from it. Payment
* payment, instead of the vendor's own Cart::createOrder() call. * must never receive a Cart (see docs/payments.md) — pay()/authorize()
* already receive $amount explicitly as their own required Lunar Price
* parameter (see PaymentResult's own docblock), the caller's job to
* assemble, same as every other driver.
* *
* This is a fork, not a decoration: StripePaymentType::authorize() is * Every amount that crosses this class's own boundary is converted right
* `final` and calls Cart::createOrder() directly with no seam to redirect * there: Lunar's Price -> Stripe's minor-unit int going INTO a gateway
* that one call — so this class reimplements authorize()'s logic (intent * call (StripeManager::toStripeAmount()), Stripe's response amount ->
* retrieval, capture-on-policy, status mapping via UpdateOrderFromIntent) * Lunar's Price coming back OUT (StripeManager::fromStripeAmount()).
* rather than wrapping the vendor method. Kept deliberately close to the * Nothing outside this class ever sees a Stripe-scaled integer.
* original so a lunarphp/stripe upgrade is easy to diff against. See *
* docs/payments.md. * Correlating a later handleCallback() (a separate request — a webhook)
* back to whatever $context identified this attempt is solved the same
* way lunarphp/stripe's own StripePaymentType/ProcessStripeWebhook solve
* it: real cart_id/order_id columns on Lunar\Stripe\Models\
* StripePaymentIntent (a table already owned by lunarphp/stripe, already
* shaped for exactly this), not a generic context blob. See
* docs/payments.md "Async resolution" for the full reasoning.
*/ */
class StripePaymentDriver implements PaymentDriver class StripePaymentDriver implements
Configurable,
SupportsPay,
SupportsAuthorization,
SupportsCaptures,
SupportsVoids,
SupportsRefunds,
HandlesPaymentCallback
{ {
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'))) — no key, no
* StripeManager::__construct()) — no key, no usable driver. * usable driver.
*/ */
public function isConfigured(): bool public function isConfigured(): bool
{ {
@@ -46,66 +72,312 @@ class StripePaymentDriver implements PaymentDriver
} }
/** /**
* @throws PaymentNotConfirmedException if Stripe hasn't confirmed the * Atomic charge — capture_method: automatic. Stripe still frequently
* payment intent (wrong intent id, already processed, order already * confirms into requires_action/requires_confirmation rather than
* placed, or the gateway call itself fails) — nothing here should be * succeeded in the same call (3-D Secure, most real cards) — Pending
* treated as "place the order anyway." * is a normal outcome here, not an edge case, resolved later via
* @throws FingerprintMismatchException * handleCallback().
* @throws CartException
*/ */
public function confirm(Cart $cart, string $type, string $fingerprint, array $data): Order public function pay(string $type, Price $amount, array $data = [], array $context = []): PaymentResult
{ {
$paymentIntentId = $data['payment_intent']; return $this->createAndConfirm($type, $amount, $data, $context, captureMethod: 'automatic');
}
$paymentIntentModel = StripePaymentIntent::where('intent_id', $paymentIntentId)->first(); /**
* Hold only — capture_method: manual. Resolves to Pending or an
* authorized (requires_capture) intent, never succeeded directly:
* Stripe never captures on its own for a manual intent.
*/
public function authorize(string $type, Price $amount, array $data = [], array $context = []): PaymentResult
{
return $this->createAndConfirm($type, $amount, $data, $context, captureMethod: 'manual');
}
if ($paymentIntentModel && ! $paymentIntentModel->isActive()) { private function createAndConfirm(string $type, Price $amount, array $data, array $context, string $captureMethod): PaymentResult
throw new PaymentNotConfirmedException('Payment intent already processed.'); {
} try {
$paymentIntent = Stripe::getClient()->paymentIntents->create([
if (! $paymentIntentModel) { 'amount' => StripeManager::toStripeAmount($amount->value, $amount->currency),
$paymentIntentModel = StripePaymentIntent::create([ 'currency' => $amount->currency->code,
'intent_id' => $paymentIntentId, 'capture_method' => $captureMethod,
'cart_id' => $cart->id, 'confirm' => true,
'payment_method' => $data['payment_method'] ?? null,
'automatic_payment_methods' => isset($data['payment_method'])
? null
: ['enabled' => true],
]); ]);
} catch (ApiErrorException $e) {
return $this->declined($type, $amount, $e, $context, authorizing: $captureMethod === 'manual');
} }
$paymentIntentModel->update(['processing_at' => now()]); $this->rememberIntent($paymentIntent, $type, $context);
$stripe = Stripe::getClient(); return $this->resultFromIntent($type, $paymentIntent, $amount, $context, authorizing: $captureMethod === 'manual');
$paymentIntent = $stripe->paymentIntents->retrieve($paymentIntentId); }
if (! $paymentIntent) { public function handleCallback(string $reference, array $data, array $context = []): PaymentResult
throw new PaymentNotConfirmedException('Unable to locate payment intent.'); {
[$intentModel, $type, $context] = $this->resolveIntentModel($reference, $context, $data['type'] ?? '');
$paymentIntent = Stripe::getClient()->paymentIntents->retrieve($reference);
$authorizing = $paymentIntent->capture_method === PaymentIntent::CAPTURE_METHOD_MANUAL;
if ($paymentIntent->status === PaymentIntent::STATUS_REQUIRES_CAPTURE && ! $authorizing) {
// automatic capture_method, but Stripe stopped short of
// capturing (rare, but the API contract allows it) — finish
// the job pay() started.
$paymentIntent = Stripe::getClient()->paymentIntents->capture($reference);
} }
$policy = config('lunar.stripe.policy', 'automatic'); $intentModel?->update(['status' => $paymentIntent->status]);
if ($paymentIntent->status === PaymentIntent::STATUS_REQUIRES_CAPTURE && $policy === 'automatic') { $amount = $this->priceFromIntent($paymentIntent);
$paymentIntent = $stripe->paymentIntents->capture($paymentIntentId);
}
if ($paymentIntent->status !== PaymentIntent::STATUS_SUCCEEDED) { return $this->resultFromIntent($type, $paymentIntent, $amount, $context, $authorizing);
$paymentIntentModel->update(['status' => $paymentIntent->status]); }
throw new PaymentNotConfirmedException( public function capture(string $reference, Price $amount, array $context = []): PaymentResult
$paymentIntent->last_payment_error->message ?? "Payment intent status: {$paymentIntent->status}." {
); [$intentModel, $type, $context] = $this->resolveIntentModel($reference, $context);
}
try { try {
$order = $this->checkout->placeOrder($fingerprint); $paymentIntent = Stripe::getClient()->paymentIntents->capture($reference, [
} catch (DisallowMultipleCartOrdersException|CartException $e) { 'amount_to_capture' => StripeManager::toStripeAmount($amount->value, $amount->currency),
throw new PaymentNotConfirmedException($e->getMessage(), previous: $e); ]);
} catch (ApiErrorException $e) {
$result = $this->failure($amount, $e, $reference);
PaymentCaptureFailed::dispatch($type, $result, $context);
return $result;
} }
$paymentIntentModel->order_id = $order->id; $intentModel?->update(['status' => $paymentIntent->status]);
$paymentIntentModel->status = $paymentIntent->status;
$paymentIntentModel->processed_at = now();
$paymentIntentModel->save();
UpdateOrderFromIntent::execute($order, $paymentIntent); $result = new PaymentResult(
status: $paymentIntent->status === PaymentIntent::STATUS_SUCCEEDED
? PaymentResultStatus::Succeeded
: PaymentResultStatus::Failed,
reference: $paymentIntent->id,
amount: $amount,
raw: $paymentIntent->toArray(),
);
return $order->refresh(); $paymentIntent->status === PaymentIntent::STATUS_SUCCEEDED
? PaymentCaptured::dispatch($type, $result, $context)
: PaymentCaptureFailed::dispatch($type, $result, $context);
return $result;
}
public function void(string $reference, Price $amount, array $context = []): PaymentResult
{
[$intentModel, $type, $context] = $this->resolveIntentModel($reference, $context);
try {
$paymentIntent = Stripe::getClient()->paymentIntents->cancel($reference);
} catch (ApiErrorException $e) {
$result = $this->failure($amount, $e, $reference);
PaymentVoidFailed::dispatch($type, $result, $context);
return $result;
}
$intentModel?->update(['status' => $paymentIntent->status]);
$result = new PaymentResult(
status: $paymentIntent->status === PaymentIntent::STATUS_CANCELED
? PaymentResultStatus::Succeeded
: PaymentResultStatus::Failed,
reference: $paymentIntent->id,
amount: $amount,
raw: $paymentIntent->toArray(),
);
$paymentIntent->status === PaymentIntent::STATUS_CANCELED
? PaymentVoided::dispatch($type, $result, $context)
: PaymentVoidFailed::dispatch($type, $result, $context);
return $result;
}
public function refund(string $reference, Price $amount, array $context = []): PaymentResult
{
[$intentModel, $type, $context] = $this->resolveIntentModel($reference, $context);
try {
$refund = Stripe::getClient()->refunds->create([
'payment_intent' => $reference,
'amount' => StripeManager::toStripeAmount($amount->value, $amount->currency),
]);
} catch (ApiErrorException $e) {
$result = $this->failure($amount, $e, $reference);
PaymentRefundFailed::dispatch($type, $result, $context);
return $result;
}
$result = new PaymentResult(
status: $refund->status !== 'failed' ? PaymentResultStatus::Succeeded : PaymentResultStatus::Failed,
reference: $refund->id,
amount: $amount,
raw: $refund->toArray(),
);
$refund->status !== 'failed'
? PaymentRefunded::dispatch($type, $result, $context)
: PaymentRefundFailed::dispatch($type, $result, $context);
return $result;
}
private function rememberIntent(PaymentIntent $paymentIntent, string $type, array $context): ?StripePaymentIntent
{
if (! ($context['cart_id'] ?? null)) {
return null;
}
return StripePaymentIntent::create([
'intent_id' => $paymentIntent->id,
'cart_id' => $context['cart_id'],
'order_id' => $context['order_id'] ?? null,
'status' => $paymentIntent->status,
'payment_type' => $type,
'context' => json_encode($context),
]);
}
/**
* The one lookup every method past initiate() shares: find the
* StripePaymentIntent row this $reference belongs to, then recover
* $type/$context from it — the original context, if any, always
* takes precedence over whatever the caller passed in (see
* handleCallback()'s own note: a webhook caller usually has none of
* its own).
*
* $typeFallback only matters when there's no $intentModel to read
* payment_type from — handleCallback() has its own $data['type'] to
* fall back to; capture()/void()/refund() have nothing better than ''.
*
* @return array{0: ?StripePaymentIntent, 1: string, 2: array<string, mixed>}
*/
private function resolveIntentModel(string $reference, array $context, string $typeFallback = ''): array
{
$intentModel = StripePaymentIntent::where('intent_id', $reference)->first();
return [
$intentModel,
$intentModel?->payment_type ?? $typeFallback,
$this->decodeContext($intentModel) ?? $context,
];
}
/**
* StripePaymentIntent is a vendor model (lunarphp/stripe) with no cast
* declared for our own 'context' column (added by boboko-core's own
* migration, see database/migrations/..._add_context_to_stripe_
* payment_intents.php) — we can't edit the vendor model to add one, so
* decode manually here instead of assuming Eloquent already did it.
*
* @return array<string, mixed>|null
*/
private function decodeContext(?StripePaymentIntent $intentModel): ?array
{
if (! $intentModel || ! $intentModel->context) {
return null;
}
return json_decode($intentModel->context, associative: true) ?: null;
}
/**
* Converts a live Stripe PaymentIntent's own amount/currency back
* into Lunar's Price — the one place this class reads a Stripe
* response's amount without already holding the Price that produced
* it (handleCallback() has no $data['amount'] to fall back on, unlike
* pay()/authorize()).
*/
private function priceFromIntent(PaymentIntent $paymentIntent): Price
{
$currency = Currency::whereRaw('lower(code) = ?', [strtolower($paymentIntent->currency)])->firstOrFail();
return new Price(
(int) StripeManager::fromStripeAmount($paymentIntent->amount, $currency),
$currency,
);
}
private function resultFromIntent(
string $type,
PaymentIntent $paymentIntent,
Price $amount,
array $context,
bool $authorizing,
): PaymentResult {
$status = match ($paymentIntent->status) {
PaymentIntent::STATUS_SUCCEEDED => PaymentResultStatus::Succeeded,
PaymentIntent::STATUS_REQUIRES_CAPTURE => $authorizing ? PaymentResultStatus::Succeeded : PaymentResultStatus::Pending,
PaymentIntent::STATUS_CANCELED => PaymentResultStatus::Failed,
default => PaymentResultStatus::Pending,
};
$continuation = $status === PaymentResultStatus::Pending
? new PaymentContinuation(PaymentContinuationType::ClientSecret, $paymentIntent->client_secret)
: null;
$result = new PaymentResult(
status: $status,
reference: $paymentIntent->id,
amount: $amount,
failureReason: $paymentIntent->last_payment_error->message ?? null,
raw: $paymentIntent->toArray(),
continuation: $continuation,
);
if ($status === PaymentResultStatus::Pending) {
return $result;
}
$succeeded = $status === PaymentResultStatus::Succeeded;
if ($authorizing) {
$succeeded
? PaymentAuthorized::dispatch($type, $result, $context)
: PaymentAuthorizationFailed::dispatch($type, $result, $context);
} else {
$succeeded
? PaymentCaptured::dispatch($type, $result, $context)
: PaymentCaptureFailed::dispatch($type, $result, $context);
}
return $result;
}
private function declined(string $type, Price $amount, ApiErrorException $e, array $context, bool $authorizing): PaymentResult
{
$result = $this->failure($amount, $e);
$authorizing
? PaymentAuthorizationFailed::dispatch($type, $result, $context)
: PaymentCaptureFailed::dispatch($type, $result, $context);
return $result;
}
private function failure(Price $amount, ApiErrorException $e, string $reference = ''): PaymentResult
{
$stripeError = $e->getError();
return new PaymentResult(
status: PaymentResultStatus::Failed,
reference: $reference ?: ($stripeError->payment_intent->id ?? ''),
amount: $amount,
failureReason: $e->getMessage(),
retriable: in_array($stripeError->decline_code ?? null, [
'do_not_honor', 'insufficient_funds', 'card_velocity_exceeded',
'processing_error', 'try_again_later', 'issuer_not_available',
], true),
raw: $stripeError?->toArray() ?? [],
);
} }
} }
@@ -0,0 +1,17 @@
<?php
namespace Modules\Core\Payment\Enums;
/**
* What a caller of a Pending PaymentResult needs to do next, gateway-
* agnostically. Only meaningful when PaymentResult::$continuation is not
* null (status === Pending).
*/
enum PaymentContinuationType
{
/** Send the shopper to $continuation->value (a URL) — a redirect-based gateway. */
case Redirect;
/** Hand $continuation->value (a client secret) to frontend JS — Stripe Elements-style. */
case ClientSecret;
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace Modules\Core\Payment\Enums;
/**
* The normalized outcome of a single gateway operation (pay, authorize,
* capture, void, refund) — never the gateway's own raw status string
* (Stripe's "succeeded", Nexi's "EXECUTED", Mastercard's own codes), so a
* caller never needs gateway-specific knowledge to know what happened.
*/
enum PaymentResultStatus
{
case Succeeded;
case Failed;
/**
* The gateway hasn't resolved this operation yet and won't in the same
* call — e.g. Stripe's requires_action, a redirect the shopper hasn't
* completed. A driver returning this from initiate()/handleCallback()
* has not yet dispatched a terminal event; something else (a later
* callback) is expected to resolve it.
*/
case Pending;
}
@@ -0,0 +1,27 @@
<?php
namespace Modules\Core\Payment\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Modules\Core\Payment\DTOs\PaymentResult;
/**
* Dispatched when SupportsAuthorization::authorize() (or a
* HandlesPaymentCallback::handleCallback() resolving it later) determines
* the gateway did not grant the requested hold. $result->retriable is how
* a listener knows whether "try again with the same method" is reasonable
* — see PaymentResult's own docblock.
*/
class PaymentAuthorizationFailed
{
use Dispatchable;
/**
* @param array<string, mixed> $context
*/
public function __construct(
public readonly string $type,
public readonly PaymentResult $result,
public readonly array $context = [],
) {}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace Modules\Core\Payment\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Modules\Core\Payment\DTOs\PaymentResult;
/**
* Dispatched by a driver's SupportsAuthorization::authorize() (or, for an
* async gateway, HandlesPaymentCallback::handleCallback() resolving that
* same authorize() call later) once a hold has been placed — no funds have
* moved yet, see SupportsCaptures/SupportsVoids for what happens next.
*
* Carries $result (the full PaymentResult, not just a reference) plus
* $type and $context — same reasoning throughout Payment's events: Payment
* has no concept of a cart, an order, or a checkout fingerprint, so
* whatever a listener needs to react travels through $context untouched,
* opaque to Payment itself.
*/
class PaymentAuthorized
{
use Dispatchable;
/**
* @param array<string, mixed> $context
*/
public function __construct(
public readonly string $type,
public readonly PaymentResult $result,
public readonly array $context = [],
) {}
}
@@ -0,0 +1,29 @@
<?php
namespace Modules\Core\Payment\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Modules\Core\Payment\DTOs\PaymentResult;
/**
* Dispatched when SupportsPay::pay() or SupportsCaptures::capture() (or a
* HandlesPaymentCallback::handleCallback() resolving either later) fails
* to take the money — whether that's a sale-mode gateway declining the
* charge outright, or a capture call against an existing authorization
* being rejected. Same terminal-event symmetry as PaymentCaptured: this is
* the one "capture attempt failed" event regardless of which path
* produced it.
*/
class PaymentCaptureFailed
{
use Dispatchable;
/**
* @param array<string, mixed> $context
*/
public function __construct(
public readonly string $type,
public readonly PaymentResult $result,
public readonly array $context = [],
) {}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace Modules\Core\Payment\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Modules\Core\Payment\DTOs\PaymentResult;
/**
* The one "money has actually been taken" event, dispatched from either of
* two different call paths that end at the same business fact:
* - SupportsPay::pay() — an atomic authorize+capture gateway call
* (Mastercard's "Pay", Stripe's capture_method=automatic, an offline
* driver's immediate success).
* - SupportsCaptures::capture() — settling a PRIOR authorize() hold.
* Whether the money moved in one gateway call or two is a driver-internal
* detail; a listener reacting to "a payment has been captured" never
* needs to know or care which path produced this event.
*/
class PaymentCaptured
{
use Dispatchable;
/**
* @param array<string, mixed> $context
*/
public function __construct(
public readonly string $type,
public readonly PaymentResult $result,
public readonly array $context = [],
) {}
}
@@ -0,0 +1,12 @@
<?php
namespace Modules\Core\Payment\Events;
use Modules\Core\Payment\Models\PaymentMethod;
class PaymentMethodCreated
{
public function __construct(
public readonly PaymentMethod $method,
) {}
}
@@ -0,0 +1,15 @@
<?php
namespace Modules\Core\Payment\Events;
class PaymentMethodDeleted
{
/**
* @param array<string, mixed> $method Snapshot of the deleted row —
* already gone from the database by dispatch time, so this can't be
* a fresh PaymentMethod model instance.
*/
public function __construct(
public readonly array $method,
) {}
}
@@ -0,0 +1,17 @@
<?php
namespace Modules\Core\Payment\Events;
use Modules\Core\Payment\Models\PaymentMethod;
class PaymentMethodUpdated
{
/**
* @param array<string, mixed> $old Snapshot of the changed attributes
* before the update.
*/
public function __construct(
public readonly PaymentMethod $method,
public readonly array $old,
) {}
}
@@ -0,0 +1,17 @@
<?php
namespace Modules\Core\Payment\Events;
class PaymentMethodsReordered
{
/**
* @param array<int, int> $ids PaymentMethod ids, in their new order —
* the same array Filament's own reorderTable() already wrote to the
* database directly (bulk SQL, not PaymentMethodService::update() —
* see PaymentMethodResource's own docblock for why this is the one
* PaymentMethod write that doesn't go through the service).
*/
public function __construct(
public readonly array $ids,
) {}
}
@@ -0,0 +1,24 @@
<?php
namespace Modules\Core\Payment\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Modules\Core\Payment\DTOs\PaymentResult;
/**
* Dispatched when SupportsRefunds::refund() fails to return settled funds
* — e.g. the gateway rejects refunding more than was originally captured.
*/
class PaymentRefundFailed
{
use Dispatchable;
/**
* @param array<string, mixed> $context
*/
public function __construct(
public readonly string $type,
public readonly PaymentResult $result,
public readonly array $context = [],
) {}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace Modules\Core\Payment\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Modules\Core\Payment\DTOs\PaymentResult;
/**
* Dispatched by SupportsRefunds::refund() once settled funds (from a prior
* pay() or capture()) have actually been returned — full or partial.
* Independent of whether the original settlement was a sale or an
* authorize-then-capture: a refund only ever targets money that was
* genuinely taken, regardless of how it got taken.
*/
class PaymentRefunded
{
use Dispatchable;
/**
* @param array<string, mixed> $context
*/
public function __construct(
public readonly string $type,
public readonly PaymentResult $result,
public readonly array $context = [],
) {}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace Modules\Core\Payment\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Modules\Core\Payment\DTOs\PaymentResult;
/**
* Dispatched when SupportsVoids::void() fails to release a prior
* authorization — e.g. the hold already expired or was already captured,
* so there was nothing left to void.
*/
class PaymentVoidFailed
{
use Dispatchable;
/**
* @param array<string, mixed> $context
*/
public function __construct(
public readonly string $type,
public readonly PaymentResult $result,
public readonly array $context = [],
) {}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace Modules\Core\Payment\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Modules\Core\Payment\DTOs\PaymentResult;
/**
* Dispatched by SupportsVoids::void() once a prior authorization hold has
* been released without ever settling — the "actually, never mind" exit
* from an authorize() that SupportsCaptures::capture() would otherwise
* have settled. No funds ever moved, so this is distinct from
* PaymentRefunded (which reverses money that was actually taken).
*/
class PaymentVoided
{
use Dispatchable;
/**
* @param array<string, mixed> $context
*/
public function __construct(
public readonly string $type,
public readonly PaymentResult $result,
public readonly array $context = [],
) {}
}
@@ -1,22 +0,0 @@
<?php
namespace Modules\Core\Payment\Exceptions;
use RuntimeException;
use Throwable;
/**
* Thrown by a Modules\Core\Checkout\Contracts\PaymentDriver when the
* gateway has not confirmed payment — wrong/expired intent, already
* processed, or the gateway itself rejects the confirmation. A driver
* throws this instead of silently placing the order: CheckoutService::
* placeOrder() must only ever be called once a driver has positively
* confirmed payment, never as a fallback.
*/
class PaymentNotConfirmedException extends RuntimeException
{
public function __construct(string $message, ?Throwable $previous = null)
{
parent::__construct($message, previous: $previous);
}
}
@@ -3,21 +3,57 @@
namespace Modules\Core\Payment\Filament\Resources; namespace Modules\Core\Payment\Filament\Resources;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Resources\Resource; use Filament\Resources\Resource;
use Filament\Schemas\Components\Component;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\ToggleColumn; use Filament\Tables\Columns\ToggleColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Support\Facades\Event;
use Modules\Core\Payment\Events\PaymentMethodsReordered;
use Modules\Core\Payment\Filament\Resources\PaymentMethodResource\Pages\ListPaymentMethods; use Modules\Core\Payment\Filament\Resources\PaymentMethodResource\Pages\ListPaymentMethods;
use Modules\Core\Payment\Models\PaymentMethod; use Modules\Core\Payment\Models\PaymentMethod;
use Modules\Core\Payment\Services\PaymentDriverRegistry;
use Modules\Core\Payment\Services\PaymentMethodCache;
use Modules\Core\Payment\Services\PaymentMethodService;
/** /**
* One row per payment type key (config('lunar.payments.types')), seeded by * The DB-instance layer for Payment (see docs/payments.md) — admin
* InstallLunarCommand — never created/deleted here, only edited. `enabled` * creatable/deletable, same as Lunar's own ShippingMethodResource. A row's
* toggles inline; `data.fee` (currently the only type-specific setting, for * `driver` is picked from a Select populated by
* cash-on-delivery's flat surcharge — see ApplyCashOnDeliveryFee) is edited * PaymentDriverRegistry::labels() (mirrors Modules\Core\Shipping\
* via a modal action rather than a dedicated form field, since not every * Extensions\ShippingMethodResourceExtension::driverSelect()'s use of
* type has the same data keys. * Shipping::getSupportedDrivers()), not a hardcoded options list, and
* never the raw driver class name — a third-party driver registered from
* its own package's service provider shows up here with no change to
* this class.
*
* Every write goes through Modules\Core\Payment\Services\
* PaymentMethodService — create/edit/delete/the enabled toggle all call
* it, not PaymentMethod::create()/update()/delete() directly, so cache
* invalidation and event dispatch happen in one place. The ONE exception
* is drag-to-reorder: Filament's own reorderTable() always writes the new
* `position` values via its own raw bulk SQL query before our
* afterReordering() hook ever runs — there is no seam to route that
* specific write through the service (short of disabling drag-reorder
* entirely and rebuilding it from scratch), so that hook only forgets the
* cache and dispatches PaymentMethodsReordered; the data itself is
* already correct in the database by the time it fires.
*
* `driver_missing_at` (set by the `boboko:payment:sync-drivers` command
* when a row's driver no longer resolves) is surfaced as its own table
* column, deliberately distinct from `enabled` — an admin needs to tell
* "I turned this off" apart from "the driver code was removed" at a
* glance, not have both look like the same disabled state.
*
* `authorized_status` only appears in the form when `capture_mode` is
* "Hold now, charge later" — it's simply unreachable for a "Charge
* immediately" method (that mode only ever produces PaymentCaptured,
* never PaymentAuthorized), so showing it unconditionally would just be
* a confusing, always-irrelevant field for most methods.
*/ */
class PaymentMethodResource extends Resource class PaymentMethodResource extends Resource
{ {
@@ -35,10 +71,31 @@ class PaymentMethodResource extends Resource
{ {
return $table return $table
->columns([ ->columns([
TextColumn::make('position')
->label('Order')
->sortable(),
TextColumn::make('name')
->label('Name')
->searchable(),
TextColumn::make('type') TextColumn::make('type')
->label('Type'), ->label('Type'),
TextColumn::make('driver')
->label('Driver')
->formatStateUsing(fn (?string $state) => static::driverLabel($state)),
IconColumn::make('driver_missing_at')
->label('Driver status')
->boolean()
->trueIcon('heroicon-o-exclamation-triangle')
->falseIcon('heroicon-o-check-circle')
->trueColor('danger')
->falseColor('success')
->tooltip(fn (PaymentMethod $record) => $record->driver_missing_at
? 'Driver not found as of '.$record->driver_missing_at->diffForHumans()
: 'Driver resolves correctly'),
ToggleColumn::make('enabled') ToggleColumn::make('enabled')
->label('Enabled'), ->label('Enabled')
->updateStateUsing(fn (PaymentMethod $record, $state) => app(PaymentMethodService::class)
->update($record, ['enabled' => $state])),
TextColumn::make('data.fee') TextColumn::make('data.fee')
->label('Fee') ->label('Fee')
->formatStateUsing(fn (?int $state) => $state ->formatStateUsing(fn (?int $state) => $state
@@ -48,10 +105,110 @@ class PaymentMethodResource extends Resource
->label('Last updated') ->label('Last updated')
->dateTime(), ->dateTime(),
]) ])
->reorderable('position')
->afterReordering(function (array $order) {
app(PaymentMethodCache::class)->forget();
Event::dispatch(new PaymentMethodsReordered(array_map('intval', array_values($order))));
})
->recordActions([ ->recordActions([
static::editAction(),
static::editFeeAction(), static::editFeeAction(),
static::deleteAction(),
]) ])
->defaultSort('type'); ->defaultSort('position');
}
/**
* @return array<Component>
*/
public static function getFormComponents(): array
{
return [
TextInput::make('name')
->label('Name')
->required()
->maxLength(255),
TextInput::make('type')
->label('Type')
->helperText('Machine-facing slug — stored on the cart/order, used by other code to identify this method. Cannot be changed once orders reference it.')
->required()
->unique(ignoreRecord: true)
->maxLength(255),
static::getDriverFormComponent(),
Select::make('capture_mode')
->label('Capture mode')
->helperText('Whether checkout charges immediately, or places a hold to settle later.')
->options([
'pay' => 'Charge immediately',
'authorize' => 'Hold now, charge later',
])
->default('pay')
->live()
->required(),
static::getOrderStatusSelect('captured_status', 'Order status once paid')
->helperText('Applied the moment a payment is fully charged.'),
static::getOrderStatusSelect('authorized_status', 'Order status once held')
->helperText('Applied the moment a hold is placed, before it\'s charged.')
->visible(fn (Get $get) => $get('capture_mode') === 'authorize'),
static::getOrderStatusSelect('refunded_status', 'Order status once refunded')
->helperText('Applied when a payment taken through this method is refunded — even if the refund itself is processed through a different method.'),
];
}
public static function getDriverFormComponent(): Component
{
return Select::make('driver')
->label('Driver')
->options(fn () => app(PaymentDriverRegistry::class)->labels())
->required();
}
/**
* Lunar's own Order::status is a plain, admin-extensible string
* (config('lunar.orders.statuses')) rather than a fixed enum —
* deliberately so a store can add its own custom status without a
* code change (see docs/payments.md). This Select still reads from
* that same open-ended list, just so an admin picks a real status
* instead of typing a slug from memory.
*/
private static function getOrderStatusSelect(string $name, string $label): Select
{
return Select::make($name)
->label($label)
->options(collect(config('lunar.orders.statuses', []))
->map(fn (array $status) => $status['label'] ?? $status)
->all())
->native(false);
}
public static function getPages(): array
{
return [
'index' => ListPaymentMethods::route('/'),
];
}
public static function canCreate(): bool
{
return true;
}
public static function canDelete($record = null): bool
{
return true;
}
private static function editAction(): Action
{
return Action::make('edit')
->label('Edit')
->icon('heroicon-o-pencil-square')
->schema(static::getFormComponents())
->fillForm(fn (PaymentMethod $record) => $record->only([
'name', 'type', 'driver', 'capture_mode', 'captured_status', 'authorized_status', 'refunded_status',
]))
->action(fn (PaymentMethod $record, array $data) => app(PaymentMethodService::class)->update($record, $data));
} }
/** /**
@@ -76,7 +233,7 @@ class PaymentMethodResource extends Resource
'fee' => filled($record->data['fee'] ?? null) ? $record->data['fee'] / 100 : null, 'fee' => filled($record->data['fee'] ?? null) ? $record->data['fee'] / 100 : null,
]) ])
->action(function (PaymentMethod $record, array $data) { ->action(function (PaymentMethod $record, array $data) {
$record->update([ app(PaymentMethodService::class)->update($record, [
'data' => [ 'data' => [
...$record->data->toArray(), ...$record->data->toArray(),
'fee' => filled($data['fee']) ? (int) round($data['fee'] * 100) : null, 'fee' => filled($data['fee']) ? (int) round($data['fee'] * 100) : null,
@@ -85,20 +242,22 @@ class PaymentMethodResource extends Resource
}); });
} }
public static function getPages(): array private static function deleteAction(): Action
{ {
return [ return Action::make('delete')
'index' => ListPaymentMethods::route('/'), ->label('Delete')
]; ->icon('heroicon-o-trash')
->color('danger')
->requiresConfirmation()
->action(fn (PaymentMethod $record) => app(PaymentMethodService::class)->delete($record));
} }
public static function canCreate(): bool private static function driverLabel(?string $key): string
{ {
return false; if ($key === null) {
} return '—';
}
public static function canDelete($record = null): bool return app(PaymentDriverRegistry::class)->label($key) ?? $key;
{
return false;
} }
} }
@@ -2,10 +2,31 @@
namespace Modules\Core\Payment\Filament\Resources\PaymentMethodResource\Pages; namespace Modules\Core\Payment\Filament\Resources\PaymentMethodResource\Pages;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
use Modules\Core\Payment\Filament\Resources\PaymentMethodResource; use Modules\Core\Payment\Filament\Resources\PaymentMethodResource;
use Modules\Core\Payment\Models\PaymentMethod;
use Modules\Core\Payment\Services\PaymentMethodService;
class ListPaymentMethods extends ListRecords class ListPaymentMethods extends ListRecords
{ {
protected static string $resource = PaymentMethodResource::class; protected static string $resource = PaymentMethodResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make()
->schema(PaymentMethodResource::getFormComponents())
->fillForm(fn () => [
'position' => (PaymentMethod::max('position') ?? 0) + 1,
'enabled' => false,
'data' => [],
])
// Every PaymentMethod write goes through PaymentMethodService
// — see PaymentMethodResource's own docblock — so this
// replaces CreateAction's default $model::create($data), not
// just the form/fill behavior above.
->using(fn (array $data) => app(PaymentMethodService::class)->create($data)),
];
}
} }
@@ -0,0 +1,46 @@
<?php
namespace Modules\Core\Payment\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Modules\Core\Payment\Drivers\StripePaymentDriver;
use Stripe\Webhook;
/**
* A boboko-owned webhook endpoint for Stripe — deliberately NOT
* lunarphp/stripe's own route (vendor/lunarphp/stripe/routes/webhooks.php),
* which dispatches into Lunar's own Payments::driver('stripe') flow (the
* flow StripePaymentDriver was built to replace, see that class's own
* docblock). Signature verification is handled by
* Lunar\Stripe\Http\Middleware\StripeWebhookMiddleware, registered on this
* route (see src/Payment/routes/webhooks.php) — pure Stripe SDK
* verification + event-type filtering, safe to reuse even though this
* controller never touches the rest of that vendor package's flow. This
* controller verifies the signature again itself (Webhook::constructEvent())
* to get the constructed Event object — the middleware doesn't stash one
* anywhere reusable, it only gates the request through.
*
* Resolves the driver directly by class, not via
* Modules\Core\Payment\Services\PaymentDriverRegistry — this endpoint is
* inherently Stripe-specific (Stripe's own webhook payload carries no
* boboko payment-type key, only its own payment_intent id), and
* StripePaymentDriver::handleCallback() already recovers $type itself
* from the StripePaymentIntent row pay()/authorize() wrote.
*/
class StripeWebhookController extends Controller
{
public function __invoke(Request $request, StripePaymentDriver $driver): JsonResponse
{
$event = Webhook::constructEvent(
$request->getContent(),
$request->header('Stripe-Signature'),
config('services.stripe.webhooks.lunar'),
);
$driver->handleCallback($event->data->object->id, $event->data->object->toArray());
return response()->json(['webhook_successful' => true]);
}
}
@@ -0,0 +1,60 @@
<?php
namespace Modules\Core\Payment\Listeners;
use Modules\Core\Logging\ActivityLogService;
use Modules\Core\Payment\Events\PaymentMethodCreated;
use Modules\Core\Payment\Events\PaymentMethodDeleted;
use Modules\Core\Payment\Events\PaymentMethodUpdated;
use Modules\Core\Payment\Models\PaymentMethod;
/**
* Same pattern as Localization\Listeners\LogTranslationActivity — routes
* PaymentMethodService's own events through the existing
* Logging\ActivityLogService instead of PaymentMethod separately opting
* into Lunar\Base\Traits\LogsActivity (Spatie's generic model-observer
* logging): PaymentMethodUpdated::$old and PaymentMethodDeleted::$method
* already carry richer, deliberate before/after context than Eloquent's
* own dirty-attribute diffing would reconstruct on its own.
*
* PaymentMethodDeleted's snapshot is a plain array (the row is already
* gone from the database by dispatch time — see that event's own
* docblock), so performedOn() gets an unsaved PaymentMethod instance
* built from it purely to carry the right subject_type/id, not a real
* persisted model.
*
* PaymentMethodsReordered is deliberately NOT logged here — it's a
* multi-row position change (ActivityLogService's methods all take one
* Model $subject) for a low-stakes, purely-cosmetic setting, not worth
* forcing into a one-subject shape or adding a new method to the shared
* service for.
*/
class LogPaymentMethodActivity
{
public function __construct(
private readonly ActivityLogService $activityLog,
) {}
public function handleCreated(PaymentMethodCreated $event): void
{
$this->activityLog->created($event->method, $event->method->getAttributes());
}
public function handleUpdated(PaymentMethodUpdated $event): void
{
$this->activityLog->updated(
$event->method,
$event->old,
$event->method->only(array_keys($event->old)),
);
}
public function handleDeleted(PaymentMethodDeleted $event): void
{
$subject = (new PaymentMethod)->forceFill($event->method);
$subject->exists = true;
$subject->id = $event->method['id'];
$this->activityLog->deleted($subject, $event->method);
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace Modules\Core\Payment\Models;
use Lunar\Models\Transaction;
use Modules\Core\Payment\Support\TransactionDriverAdapter;
/**
* Registered via Lunar\Facades\ModelManifest::replace(Lunar\Models\
* Contracts\Transaction::class, self::class) in PaymentServiceProvider —
* the same contract-swap mechanism this codebase already uses for
* Customer/Staff. Every place Lunar's own code resolves a transaction via
* Transaction::modelClass() (which reads this replacement, see
* Lunar\Base\Traits\HasModelExtending::modelClass()) — including
* Order::transactions()'s own hasMany(Transaction::modelClass()) relation
* — gets an instance of THIS class instead of the vendor's own
* Lunar\Models\Transaction. No override anywhere else is needed: this is
* the one seam that makes $order->transactions, and everything the admin
* panel's refund/capture actions call on one of those rows, silently run
* through our own system.
*
* Only driver() is overridden — refund()/capture()/paymentChecks() on the
* parent class all just call driver()->{method}(), so replacing what
* driver() returns is the entire fix (see TransactionDriverAdapter).
*/
class CoreTransaction extends Transaction
{
public function driver(): TransactionDriverAdapter
{
return app(TransactionDriverAdapter::class);
}
}
+31 -11
View File
@@ -6,17 +6,35 @@ use Illuminate\Database\Eloquent\Casts\AsArrayObject;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
/** /**
* Admin-editable settings for one payment type key (matching a key in * A merchant-configured payment method — the DB-instance layer, admin
* config('lunar.payments.types')) — enabled/disabled, and whatever type- * creatable/deletable, same split Modules\Core\Shipping's own
* specific data it needs (starts with 'fee' for cash-on-delivery's flat * shipping_methods table already has (see docs/payments.md):
* surcharge). Mirrors Lunar's own Discount model: a single jsonb 'data' * - type: unique, machine-facing slug (Cart::meta['payment_method'],
* column holding keyed settings, rather than a fixed column per setting or * ApplyCashOnDeliveryFee's lookup key, every Payment event's $type).
* a separate conditions table — new settings are a code change (a new key * - name: admin-facing label.
* read from data), not a migration. * - driver: the Modules\Core\Payment\Services\PaymentDriverRegistry key
* * — NOT the same as `type`, and not unique (two rows can share one
* Seeded once per type by InstallLunarCommand (skip-if-exists, same * driver, e.g. two differently-named offline-style methods).
* idempotent convention as seedStorefrontLabels()) — never auto-created on * - capture_mode: 'pay' or 'authorize' — which SupportsPay/
* read, so a read path stays a pure read. * SupportsAuthorization method CheckoutService::initiatePayment()
* calls for this row.
* - captured_status / authorized_status / refunded_status: the
* Order::status value Modules\Core\Order\Listeners\
* ApplyResolvedPaymentStatus applies on a PaymentCaptured/
* PaymentAuthorized/PaymentRefunded event. For a refund, this is
* always the ORIGINAL payment method's row (the one the customer
* actually paid with), never the driver the refund itself was routed
* through (Payment\Support\TransactionDriverAdapter::refundVia() may
* use a different one entirely — e.g. a cash-on-delivery order
* refunded via a Bank Transfer driver with no PaymentMethod row of
* its own) — see that listener's own docblock.
* - position: admin-controlled display/checkout order.
* - driver_missing_at: set by `payment:sync-drivers` when `driver` no
* longer resolves via the registry — separate from `enabled`, so a
* driver vanishing (a deploy removed it) is never confused with an
* admin's own manual toggle.
* - data: jsonb, driver-specific settings that don't warrant their own
* column (starts with 'fee', the offline flat surcharge).
*/ */
class PaymentMethod extends Model class PaymentMethod extends Model
{ {
@@ -24,6 +42,8 @@ class PaymentMethod extends Model
protected $casts = [ protected $casts = [
'enabled' => 'boolean', 'enabled' => 'boolean',
'position' => 'integer',
'driver_missing_at' => 'datetime',
'data' => AsArrayObject::class, 'data' => AsArrayObject::class,
]; ];
} }
@@ -0,0 +1,107 @@
<?php
namespace Modules\Core\Payment\Services;
/**
* Which payment driver CLASSES exist this deploy — the code-registry layer,
* mirroring Lunar\Shipping\Managers\ShippingManager's own built-in-methods
* + Manager::extend() pattern, but purpose-built rather than extending
* Illuminate\Support\Manager: Manager's create{X}Driver() convention fits
* a uniform one-interface-per-driver contract (ShippingRateInterface); a
* Payment driver instead implements several independent, opt-in capability
* interfaces at once (Configurable, SupportsPay, SupportsAuthorization,
* ...), so there's no single "the" method to generate per driver.
*
* Deliberately knows NOTHING about Modules\Core\Payment\Models\PaymentMethod
* or the database — resolve() is a pure "does this key still exist"
* lookup. Whether a resolved driver is administratively enabled, or
* reports itself Configurable::isConfigured(), is the DOMAIN's job
* (Modules\Core\Checkout\Services\CheckoutService::getPaymentMethods()) —
* see docs/payments.md. This split is what lets the identical registry
* shape be lifted for a future Invoicing/AntiFraud domain without dragging
* Payment-specific concepts along with it.
*
* Built-in drivers are registered in Modules\Core\Providers\
* PaymentServiceProvider::boot() via register(); a consuming app or a
* future payment-provider package registers its own the same way, from
* its own service provider's boot() — exactly how Shipping::extend() works
* for ACS/Box Now (src/Providers/ShippingServiceProvider.php).
*/
class PaymentDriverRegistry
{
/**
* @var array<string, string>
*/
private array $drivers = [];
/**
* @var array<string, string>
*/
private array $labels = [];
/**
* $key is the registry key a Modules\Core\Payment\Models\PaymentMethod
* row's own `driver` column stores — NOT the same as that row's `type`
* (its merchant-facing slug). Two rows can share one driver key (e.g.
* both 'cash-on-delivery' and 'cash-in-hand' using the same 'offline'
* driver with different type/name/fee).
*
* $label is a short, human-readable name (e.g. "Stripe", "Offline /
* Manual") — this is where that comes from, not $driverClass's own
* FQCN. Payment's driver classes implement several independent,
* opt-in capability interfaces (Configurable, SupportsPay, ...), none
* of which carries a display name the way Lunar\Shipping\Interfaces\
* ShippingRateInterface::name() does for every shipping driver — the
* registry is the one place that DOES know every driver at once, so
* it's the natural (and only) place to also hold this.
*/
public function register(string $key, string $driverClass, string $label): void
{
$this->drivers[$key] = $driverClass;
$this->labels[$key] = $label;
}
/**
* Null if $key was never registered — deliberately non-throwing, same
* reasoning the old PaymentDriverResolver already had: a caller
* checking availability (or the payment:sync-drivers command checking
* every PaymentMethod row) needs "not found" to be a normal, silent
* result, not an exception to catch.
*/
public function resolve(string $key): ?object
{
$driverClass = $this->drivers[$key] ?? null;
return $driverClass ? app($driverClass) : null;
}
/**
* Every registered key => driver class — what payment:sync-drivers
* checks every PaymentMethod row's `driver` column against.
*
* @return array<string, string>
*/
public function all(): array
{
return $this->drivers;
}
/**
* Every registered key => human-readable label — what a Filament
* driver Select populates its options from (mirroring
* ShippingMethodResourceExtension::driverSelect()'s use of
* Shipping::getSupportedDrivers(), which reads each driver's own
* name()) — never the raw class name from all().
*
* @return array<string, string>
*/
public function labels(): array
{
return $this->labels;
}
public function label(string $key): ?string
{
return $this->labels[$key] ?? null;
}
}
@@ -0,0 +1,41 @@
<?php
namespace Modules\Core\Payment\Services;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Modules\Core\Payment\Models\PaymentMethod;
/**
* Cached read layer over PaymentMethod — the single source both
* Modules\Core\Checkout\Services\CheckoutService (checkout-time
* availability) and anything else needing the payment-method list (e.g.
* PaymentServiceProvider's Lunar\Facades\Payments shim, order screens
* showing a transaction's driver) read from, so the table is fetched once
* per cache lifetime rather than once per caller/request. Mirrors
* Modules\Core\Localization\Services\LanguageCache's exact shape.
*
* Cached forever, invalidated via forget() by
* Modules\Core\Payment\Observers\FlushPaymentMethodCache on
* PaymentMethod::saved()/deleted() — no bespoke Created/Updated/Deleted
* event trio needed, unlike LanguageCache's (Language is a Lunar-owned
* model reacted to indirectly); PaymentMethod is entirely our own model,
* so a plain Eloquent observer is the direct route.
*/
class PaymentMethodCache
{
private const CACHE_KEY = 'core.payment.methods';
public function all(): Collection
{
return Cache::rememberForever(
self::CACHE_KEY,
fn () => PaymentMethod::query()->orderBy('position')->get(),
);
}
public function forget(): void
{
Cache::forget(self::CACHE_KEY);
}
}
@@ -0,0 +1,83 @@
<?php
namespace Modules\Core\Payment\Services;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Event;
use Modules\Core\Payment\Events\PaymentMethodCreated;
use Modules\Core\Payment\Events\PaymentMethodDeleted;
use Modules\Core\Payment\Events\PaymentMethodUpdated;
use Modules\Core\Payment\Models\PaymentMethod;
/**
* The single write (AND read) gateway for PaymentMethod — every Filament
* resource/action calls this, not PaymentMethod::create()/update()/delete()
* directly, so cache invalidation is one explicit step colocated with the
* mutation (not hidden in a model observer) and every admin change to a
* payment method dispatches a matching event, the same convention
* Modules\Core\Cart\Services\CartService already established for its own
* mutating methods.
*
* list() is what PaymentMethodCache actually reads through — see that
* class for why this needs caching at all (Modules\Core\Checkout\
* Services\CheckoutService and PaymentServiceProvider's Lunar\Facades\
* Payments shim both read the full payment-method list on the hot path).
*/
class PaymentMethodService
{
public function __construct(
private readonly PaymentMethodCache $cache,
) {}
/**
* @return Collection<int, PaymentMethod>
*/
public function list(): Collection
{
return $this->cache->all();
}
/**
* @param array<string, mixed> $data
*/
public function create(array $data): PaymentMethod
{
$method = PaymentMethod::create($data);
$this->cache->forget();
Event::dispatch(new PaymentMethodCreated($method));
return $method;
}
/**
* @param array<string, mixed> $data
*/
public function update(PaymentMethod $method, array $data): PaymentMethod
{
$old = $method->only(array_keys($data));
$method->update($data);
$this->cache->forget();
Event::dispatch(new PaymentMethodUpdated($method, $old));
return $method;
}
public function delete(PaymentMethod $method): void
{
$snapshot = $method->only([
'id', 'type', 'name', 'driver', 'capture_mode',
'captured_status', 'authorized_status', 'position', 'enabled',
]);
$method->delete();
$this->cache->forget();
Event::dispatch(new PaymentMethodDeleted($snapshot));
}
}
@@ -0,0 +1,144 @@
<?php
namespace Modules\Core\Payment\Support;
use Lunar\Base\DataTransferObjects\PaymentCapture;
use Lunar\Base\DataTransferObjects\PaymentChecks;
use Lunar\Base\DataTransferObjects\PaymentRefund;
use Lunar\DataTypes\Price;
use Lunar\Models\Contracts\Transaction;
use Modules\Core\Payment\Contracts\SupportsCaptures;
use Modules\Core\Payment\Contracts\SupportsRefunds;
use Modules\Core\Payment\Enums\PaymentResultStatus;
use Modules\Core\Payment\Services\PaymentDriverRegistry;
use Modules\Core\Payment\Services\PaymentMethodCache;
/**
* What Modules\Core\Payment\Models\CoreTransaction::driver() returns
* instead of Lunar\Facades\Payments::driver($this->driver) — the point
* where every Lunar-native caller of a transaction's driver (today: the
* admin panel's "Refund"/"Capture" header actions on the order page,
* ManageOrder::getRefundAction()/getCaptureAction() — see
* $transaction->refund()/->capture() in vendor/lunarphp/core/src/Models/
* Transaction.php) transparently lands on OUR real payment system instead
* of Lunar's own, entirely separate, unused PaymentManager.
*
* Implements Lunar\Base\PaymentTypeInterface's refund()/capture()/
* getPaymentChecks() signatures exactly — each takes the Transaction as
* its own first argument (confirmed from vendor/lunarphp/core/src/Models/
* Transaction.php: `$this->driver()->refund($this, $amount, $notes)`),
* so this class holds no transaction state of its own; CoreTransaction's
* driver() can return one shared instance for any transaction.
*
* $transaction->driver is a Modules\Core\Payment\Models\PaymentMethod.type
* value (what Modules\Core\Order\Services\TransactionRecorder writes into
* Transaction.driver) — this resolves the REAL registry key from that
* type via PaymentMethodCache, then the real driver instance from
* PaymentDriverRegistry, so refund()/capture() called here call the
* ACTUAL Stripe/etc. driver, never a fake/no-op stand-in. If either
* lookup fails (the PaymentMethod row or its driver no longer exists),
* refund()/capture() report failure rather than silently doing nothing.
*/
class TransactionDriverAdapter
{
public function __construct(
private readonly PaymentMethodCache $paymentMethods,
private readonly PaymentDriverRegistry $registry,
) {}
public function refund(Transaction $transaction, int $amount, ?string $notes = null): PaymentRefund
{
return $this->refundVia($transaction, $this->driverKeyFor($transaction), $amount, $notes);
}
/**
* The PaymentDriverRegistry key $transaction was originally taken
* through — what refund()/capture() resolve against by default, and
* what Order\Filament\Extensions\OrderRefundActionsExtension defaults
* its "Refund via" driver Select to, before an admin overrides it.
*/
public function driverKeyFor(Transaction $transaction): ?string
{
return $this->paymentMethods->all()->firstWhere('type', $transaction->driver)?->driver;
}
/**
* Same as refund(), but against an explicitly chosen driver rather than
* the one $transaction was originally taken through — e.g. refunding a
* cash-on-delivery order via a Bank Transfer driver instead of trying
* (and failing) to refund through the offline driver that took the
* original payment. $driverKey is a PaymentDriverRegistry key (e.g.
* 'bank-transfer'), not a PaymentMethod.type — the two only coincide
* when refunding through the transaction's own original driver.
*
* Called directly by Order\Filament\Extensions\
* OrderRefundActionsExtension when the admin picks a different driver
* in the refund modal, bypassing Lunar\Models\Transaction::refund()
* (whose fixed refund(int $amount, $notes = null) signature has no
* room for a driver override) — see that extension's own docblock.
*/
public function refundVia(Transaction $transaction, ?string $driverKey, int $amount, ?string $notes = null): PaymentRefund
{
$driver = $driverKey !== null ? $this->registry->resolve($driverKey) : null;
if (! $driver instanceof SupportsRefunds) {
return new PaymentRefund(success: false, message: 'This payment method does not support refunds.');
}
$result = $driver->refund(
$transaction->reference,
$this->priceFor($transaction, $amount),
['notes' => $notes, 'order_id' => $transaction->order_id],
);
return new PaymentRefund(
success: $result->status === PaymentResultStatus::Succeeded,
message: $result->failureReason,
);
}
public function capture(Transaction $transaction, int $amount = 0): PaymentCapture
{
$driver = $this->resolveDriver($transaction);
if (! $driver instanceof SupportsCaptures) {
return new PaymentCapture(success: false, message: 'This payment method does not support a separate capture step.');
}
$result = $driver->capture(
$transaction->reference,
$this->priceFor($transaction, $amount ?: $transaction->amount->value),
['order_id' => $transaction->order_id],
);
return new PaymentCapture(
success: $result->status === PaymentResultStatus::Succeeded,
message: $result->failureReason ?? '',
);
}
/**
* Lunar's own PaymentChecks DTO (address/postcode/CVC verification
* results) has no equivalent in our own contracts — none of our
* drivers currently surface this level of gateway-specific detail.
* Empty, not null: Lunar's admin panel iterates this collection to
* render a checks list, so it needs to always be a valid (possibly
* empty) PaymentChecks, never missing entirely.
*/
public function getPaymentChecks(Transaction $transaction): PaymentChecks
{
return new PaymentChecks;
}
private function resolveDriver(Transaction $transaction): ?object
{
$driverKey = $this->driverKeyFor($transaction);
return $driverKey !== null ? $this->registry->resolve($driverKey) : null;
}
private function priceFor(Transaction $transaction, int $amount): Price
{
return new Price($amount, $transaction->order->currency);
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
use Illuminate\Support\Facades\Route;
use Lunar\Stripe\Http\Middleware\StripeWebhookMiddleware;
use Modules\Core\Payment\Http\Controllers\StripeWebhookController;
Route::post(
config('payment.stripe.webhook_path', 'payments/stripe/webhook'),
StripeWebhookController::class
)
->middleware([StripeWebhookMiddleware::class, 'api'])
->withoutMiddleware([VerifyCsrfToken::class])
->name('payment.stripe.webhook');
+2 -1
View File
@@ -10,6 +10,7 @@ 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
{ {
@@ -35,7 +36,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]); $this->commands([AnonymizeCommand::class, ExportCommand::class, ExportCleanupCommand::class, ImportCommand::class, MigrateImportCommand::class, TuneProductSearchCommand::class]);
//Overriding lunar:install //Overriding lunar:install
$this->app->booted(fn () => $this->commands([InstallLunarCommand::class])); $this->app->booted(fn () => $this->commands([InstallLunarCommand::class]));
+13
View File
@@ -7,7 +7,9 @@ use Illuminate\Support\ServiceProvider;
use Lunar\Models\Order; use Lunar\Models\Order;
use Lunar\Models\Transaction; use Lunar\Models\Transaction;
use Modules\Core\Notification\NotificationRegistry; use Modules\Core\Notification\NotificationRegistry;
use Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus;
use Modules\Core\Order\Listeners\DeriveOrderDeliveredFromShipment; use Modules\Core\Order\Listeners\DeriveOrderDeliveredFromShipment;
use Modules\Core\Order\Listeners\RecordPaymentTransaction;
use Modules\Core\Order\Notifications\OrderCapturedNotification; use Modules\Core\Order\Notifications\OrderCapturedNotification;
use Modules\Core\Order\Notifications\OrderDeliveredNotification; use Modules\Core\Order\Notifications\OrderDeliveredNotification;
use Modules\Core\Order\Notifications\OrderRefundedNotification; use Modules\Core\Order\Notifications\OrderRefundedNotification;
@@ -15,6 +17,10 @@ use Modules\Core\Order\Notifications\OrderStatusUpdatedNotification;
use Modules\Core\Order\Observers\OrderObserver; use Modules\Core\Order\Observers\OrderObserver;
use Modules\Core\Order\Observers\TransactionObserver; use Modules\Core\Order\Observers\TransactionObserver;
use Modules\Core\Order\Support\OrderStatus; use Modules\Core\Order\Support\OrderStatus;
use Modules\Core\Payment\Events\PaymentAuthorized;
use Modules\Core\Payment\Events\PaymentCaptured;
use Modules\Core\Payment\Events\PaymentRefunded;
use Modules\Core\Payment\Events\PaymentVoided;
use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier; use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier;
class OrderServiceProvider extends ServiceProvider class OrderServiceProvider extends ServiceProvider
@@ -28,6 +34,13 @@ class OrderServiceProvider extends ServiceProvider
Order::macro('fulfillmentStatus', fn () => OrderStatus::fulfillment($this)); Order::macro('fulfillmentStatus', fn () => OrderStatus::fulfillment($this));
Event::listen(ShipmentStatusUpdatedByCarrier::class, DeriveOrderDeliveredFromShipment::class); Event::listen(ShipmentStatusUpdatedByCarrier::class, DeriveOrderDeliveredFromShipment::class);
Event::listen(PaymentCaptured::class, ApplyResolvedPaymentStatus::class);
Event::listen(PaymentAuthorized::class, ApplyResolvedPaymentStatus::class);
Event::listen(PaymentRefunded::class, ApplyResolvedPaymentStatus::class);
Event::listen(PaymentCaptured::class, RecordPaymentTransaction::class);
Event::listen(PaymentAuthorized::class, RecordPaymentTransaction::class);
Event::listen(PaymentVoided::class, RecordPaymentTransaction::class);
Event::listen(PaymentRefunded::class, RecordPaymentTransaction::class);
NotificationRegistry::get()->register([ NotificationRegistry::get()->register([
OrderDeliveredNotification::class, OrderDeliveredNotification::class,
+42 -6
View File
@@ -2,24 +2,37 @@
namespace Modules\Core\Providers; namespace Modules\Core\Providers;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Lunar\Facades\ModelManifest;
use Lunar\Models\Contracts\Transaction as TransactionContract;
use Lunar\Pipelines\Cart\ApplyShipping; use Lunar\Pipelines\Cart\ApplyShipping;
use Modules\Core\Command\SyncPaymentDriversCommand;
use Modules\Core\Payment\Drivers\BankTransferPaymentDriver;
use Modules\Core\Payment\Drivers\OfflinePaymentDriver;
use Modules\Core\Payment\Drivers\StripePaymentDriver;
use Modules\Core\Payment\Events\PaymentMethodCreated;
use Modules\Core\Payment\Events\PaymentMethodDeleted;
use Modules\Core\Payment\Events\PaymentMethodUpdated;
use Modules\Core\Payment\Listeners\LogPaymentMethodActivity;
use Modules\Core\Payment\Models\CoreTransaction;
use Modules\Core\Payment\Services\PaymentDriverRegistry;
class PaymentServiceProvider extends ServiceProvider class PaymentServiceProvider extends ServiceProvider
{ {
public function register(): void public function register(): void
{ {
$this->mergeConfigFrom(__DIR__ . '/../../config/payment.php', 'payment'); $this->mergeConfigFrom(__DIR__ . '/../../config/payment.php', 'payment');
$this->app->singleton(PaymentDriverRegistry::class);
} }
public function boot(): void public function boot(): void
{ {
config([ $registry = $this->app->make(PaymentDriverRegistry::class);
'lunar.payments.types' => array_merge( $registry->register('offline', OfflinePaymentDriver::class, 'Offline / Manual');
config('lunar.payments.types', []), $registry->register('stripe', StripePaymentDriver::class, 'Stripe');
config('payment.types', []) $registry->register('bank-transfer', BankTransferPaymentDriver::class, 'Bank Transfer');
),
]);
$cartPipeline = config('lunar.cart.pipelines.cart', []); $cartPipeline = config('lunar.cart.pipelines.cart', []);
$insertAfter = array_search(ApplyShipping::class, $cartPipeline, true); $insertAfter = array_search(ApplyShipping::class, $cartPipeline, true);
@@ -38,5 +51,28 @@ class PaymentServiceProvider extends ServiceProvider
} }
config(['lunar.cart.pipelines.cart' => $cartPipeline]); config(['lunar.cart.pipelines.cart' => $cartPipeline]);
// Same contract-swap mechanism this codebase already uses for
// Customer/Staff (see e.g. consuming apps' own AppServiceProvider,
// ModelManifest::replace(Contracts\Customer::class, ...)) — every
// place Lunar's own code resolves a transaction via
// Transaction::modelClass() (Order::transactions()'s own relation
// included) gets Modules\Core\Payment\Models\CoreTransaction
// instead of the vendor's own Transaction. That subclass's
// driver() override is the ENTIRE fix for the admin panel's
// refund/capture actions silently landing on our real payment
// system — see CoreTransaction's own docblock. Lunar's own
// Payments facade/PaymentManager is never touched at all.
ModelManifest::replace(TransactionContract::class, CoreTransaction::class);
Event::listen(PaymentMethodCreated::class, [LogPaymentMethodActivity::class, 'handleCreated']);
Event::listen(PaymentMethodUpdated::class, [LogPaymentMethodActivity::class, 'handleUpdated']);
Event::listen(PaymentMethodDeleted::class, [LogPaymentMethodActivity::class, 'handleDeleted']);
$this->loadRoutesFrom(__DIR__ . '/../Payment/routes/webhooks.php');
if ($this->app->runningInConsole()) {
$this->commands([SyncPaymentDriversCommand::class]);
}
} }
} }
@@ -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;
@@ -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\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
{ {