Compare commits

...
34 Commits
Author SHA1 Message Date
arvanitakis 8f4c1a22ea Bump version to 0.16.3 2026-09-10 00:14:57 +03:00
arvanitakis 13d5833d18 Fix: Fixing Stripe Payment Driver, Applying Payment Mehtod (COD) fee correctly 2026-09-10 00:14:42 +03:00
arvanitakis 3e45b84636 Bump version to 0.16.2 2026-09-09 23:46:29 +03:00
arvanitakis 437cbf2460 Fix: Updating Shipping Listeners to clear the ShippingManifest options 2026-09-09 23:45:21 +03:00
arvanitakis 5425a0396f Feat: Adding missing nav translations 2026-09-09 23:43:39 +03:00
arvanitakis 4d0e326cb9 Bump version to 0.16.1 2026-09-09 23:25:22 +03:00
arvanitakis d4f9766940 Fix: Correcting spaceing on login form 2026-09-09 23:22:13 +03:00
arvanitakis 359e1e262e Bump Version to 0.16.0 2026-09-09 01:19:31 +03:00
arvanitakis fb684dc97b Feat: Adding Concent Updates 2026-09-09 01:12:21 +03:00
arvanitakis 9c95c0bccb Bump Version to 0.15.0 2026-09-09 00:48:52 +03:00
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
arvanitakis 3497553b41 Feature: Order Service Provider, Order Events Observers 2026-09-01 14:04:12 +03:00
arvanitakis 29e77a973b Feat: Orders Feature Survey 2026-09-01 13:34:37 +03:00
118 changed files with 5649 additions and 624 deletions
+335
View File
@@ -4,6 +4,341 @@ 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.16.3] - 2026-09-10
### Fixed
- Stripe `createAndConfirm()` built its `PaymentIntent` params with
`'automatic_payment_methods' => isset($data['payment_method']) ? null : ['enabled' => true]`. The
Stripe PHP SDK does not omit `null`-valued params from `create()` — it serializes them to an empty
string (`ApiRequestor::_encodeObjects()` → `Util::utf8(null)`), and Stripe's API rejects an empty
`automatic_payment_methods`. Every Stripe charge failed before it started whenever a
`payment_method` was supplied (i.e. every real charge in this flow). Fixed by building `$params`
conditionally so the key is either omitted entirely or set to `['enabled' => true]`, never `null`.
- `Modules\Core\Payment\Filament\Resources\PaymentMethodResource`'s "Driver status" column only
flagged a payment method whose driver *class* no longer resolves (`driver_missing_at`) — it gave
no indication when a driver resolves fine but fails `Configurable::isConfigured()` (e.g. Stripe
enabled in the DB with no `services.stripe.key` set), which `CheckoutService::getPaymentMethods()`
filters out identically. An admin had no way to tell "this method is silently absent at checkout
because of missing config" from "everything's fine" at a glance. The same icon column now also
reflects `isConfigured()`, with a tooltip distinguishing "driver not found" from "missing required
configuration" from "fully configured."
- `Modules\Core\Payment\Pipelines\Cart\ApplyCashOnDeliveryFee` (now `ApplyPaymentMethodFee`) had two
stacked bugs that together meant a configured payment-method fee (e.g. €5 on Cash on Delivery)
never actually reached the cart total:
- `PaymentMethod::where(...)->value('data->fee')` silently returned `null` on Postgres — Laravel's
query builder does not translate the `->` JSON-path column-selector syntax in `value()`/`pluck()`
the way it does inside `where()` clauses, so this resolved to a discarded
`stdClass::$data->fee` property access instead of the actual fee. Fixed by loading the model and
reading the cast `->data['fee']` attribute instead.
- Even with the fee correctly read, adding it directly to `$cart->shippingTotal` didn't survive:
`Lunar\Pipelines\Cart\CalculateTax`, which runs later in the same cart-calculation pipeline,
unconditionally recomputes `shippingTotal` (and shipping tax) from `$cart->shippingBreakdown`'s
item sum — silently discarding anything set only on the plain property. Fixed by adding the fee
as its own `Lunar\Base\ValueObjects\Cart\ShippingBreakdownItem` on `shippingBreakdown` instead,
so it survives `CalculateTax`'s recompute and is correctly included in shipping tax too.
- Also generalized while fixing: the pipeline was hardcoded to the literal type string
`cash-on-delivery`. Renamed to `ApplyPaymentMethodFee` and changed it to look up whichever
`PaymentMethod` row matches `Cart::meta['payment_method']` and apply its own `data.fee` if
present — works for any payment method configured with a fee, not just one specific slug.
- `Modules\Core\Checkout\Services\CheckoutService::selectPaymentMethod()` called `$cart->calculate()`
after saving the new payment method, but `Lunar\Models\Cart::calculate()` no-ops if the cart
instance was already calculated earlier in the same request (`Cart::isCalculated()`) —
`Lunar\Managers\CartSessionManager` memoizes one `Cart` instance per request, so this was true on
every request where the checkout page's initial render had already calculated the cart. The
result: after switching payment methods, the just-saved `meta['payment_method']` change was
persisted, but the cart's totals silently kept reflecting whichever method was calculated *first*
in the request — a shopper switching from Cash in Hand to Cash on Delivery would keep seeing Cash
in Hand's total, with no COD fee applied, until something else forced a fresh calculation. Fixed
by calling `$cart->recalculate()` instead, which forces the pipeline to re-run.
## [0.16.2] - 2026-09-09
### Fixed
- `Lunar\Base\ShippingManifest` is a request-lifetime singleton whose `getOptions()` re-runs the
shipping modifier pipeline without ever clearing its `$options` collection first, and whose
`addOption()` keeps the first entry per `getIdentifier()` and silently drops any later one. In
practice, an option resolved for an earlier shipping address (or cart state) shadowed the
correct one after the address/region changed within the same request — e.g. a carrier priced
differently across two zones that both match an address would keep quoting the stale zone's
price, and `ApplyShipping` would price the cart total off that same stale option. Renamed
`Modules\Core\Shipping\Listeners\FlushLivePricingCache` to
`Modules\Core\Shipping\Listeners\InvalidateShippingOptions` and had it additionally call
`ShippingManifest::clearOptions()`, merged in because both invalidations fire on the exact same
event set (`CartLineAdded`, `CartLineUpdated`, `CartLineRemoved`, `CartCleared`,
`ShippingAddressSet`) — the only inputs the shipping modifier pipeline depends on.
## [0.16.1] - 2026-09-09
### Fixed
- OTP login page (`resources/views/auth/filament/pages/login.blade.php`) had no visible spacing
between the email/OTP input, error text, and buttons following the Filament v3 → v4 upgrade.
The view relied on a bare `grid gap-y-4` Tailwind utility class, but since this view ships from
the `boboko-core` package rather than a consuming app, that class was never present in any
host app's compiled Tailwind output. Replaced with an inline `style` (flex column, `row-gap:
1rem`) so the layout no longer depends on the consuming app's Tailwind content scanning.
## [0.16.0] - 2026-09-08
### Added
- `Modules\Core\Checkout\Services\CheckoutService::setRecoveryConsent(bool $consent): Cart` — the
shopper's promotional/abandoned-cart-recovery opt-in, given once during guest checkout and
deliberately independent of `setShippingAddress()`/`setBillingAddress()`: consent is a
cart-level decision, not tied to any one `CartAddress` — changing which address is on the cart
later never resets or re-asks for it. Only an explicit call to this method (the checkbox itself
being submitted) ever changes it; calling it again with `false` is how a later opt-out is
recorded, per the legal requirement that consent be provable and withdrawable. Stored on
`Cart::meta` (interim, per the design this implements — a real column/consent record is the
eventual target, tracked as follow-up) as `recovery_consent` (bool), `recovery_consent_at`
(ISO 8601, `null` when `false`), and `recovery_consent_policy_version`
(`config('legal.privacy_policy_version')` at the moment of consent, so a later dispute is
answered from what was actually agreed to). Dispatches new
`Modules\Core\Checkout\Events\RecoveryConsentSet`. Newsletter opt-in is explicitly a separate
scope — never merged into this flag.
- `Modules\Core\Checkout\Services\CheckoutService::initiatePayment()` now requires `bool
$termsAccepted` and `string $policyVersion` as mandatory parameters (not optional data a caller
might omit) — throws the new `Modules\Core\Checkout\Exceptions\TermsNotAcceptedException`
*before* `Cart::createOrder()` is ever called if `$termsAccepted` is `false`, so an order can
never exist without a recorded acceptance (refused, not created-then-flagged). On success,
writes `terms_accepted` (`true`), `terms_accepted_at` (ISO 8601), and
`terms_accepted_policy_version` onto the created `Order`'s own `meta` — the durable,
order-level audit trail for a consumer-contract acceptance dispute, written directly (not via
an event/listener) since the `Order` row doesn't exist until `createOrder()` returns.
- `Modules\Core\Cart\Commands\DetectAbandonedCarts` — both its `CartAbandoned` and
`CheckoutAbandoned` detection queries now require `meta->recovery_consent = true`. A
non-consenting cart's abandonment is never dispatched at all (not merely filtered later at
whatever future recovery-email send step reads it) — the correct enforcement point per the
legal requirement that recovery/marketing sends only ever reach carts that opted in.
- `config/legal.php` (merged by a new `Modules\Core\Providers\CheckoutServiceProvider`) —
`privacy_policy_version`/`terms_version`, plain `env()`-backed strings bumped by whoever edits
the corresponding legal page. Recorded alongside every consent/acceptance rather than read live
at dispute time, so what a shopper actually agreed to is answered from the cart/order itself.
`CheckoutServiceProvider` itself is new — `Checkout` previously had no dedicated service
provider at all (its service/events were resolved/dispatched without one).
## [0.15.0] - 2026-09-07
### Changed
- **Breaking:** `Modules\Core\Payment\Models\PaymentMethod` is now the full DB-instance layer for
Payment, same three-layer split (registry / DB instance / cross-cutting config) `Shipping`
already has via `ShippingMethod` — see `docs/payments.md`. Every value that used to live in
`config('lunar.payments.types.{type}.*')` (`payment_driver`, `capture_mode`, `captured_status`)
moves onto the `PaymentMethod` row itself as real columns: `driver` (the new
`PaymentDriverRegistry` key — NOT the same as `type`; two rows can share one driver), `name`
(admin-facing label, nothing played this role before), `capture_mode`, `captured_status`,
`authorized_status`, `position` (admin-controlled ordering, new — reorderable in the Filament
table), `driver_missing_at`. `config('lunar.payments.types')` is gone entirely; `config/
payment.php` now holds only `cart_pipeline` (genuinely cross-cutting — every store gets the
same pipeline wiring regardless of how many payment methods it configures).
- **Breaking:** `Modules\Core\Payment\Services\PaymentDriverResolver` is deleted, replaced by
`Modules\Core\Payment\Services\PaymentDriverRegistry` — `register(string $key, string
$driverClass)`/`resolve(string $key): ?object`/`all(): array<string, string>`. Deliberately
knows nothing about `PaymentMethod` or the database (mirrors `Lunar\Shipping\Managers\
ShippingManager`'s built-in-methods + `Manager::extend()` split, purpose-built rather than
extending `Illuminate\Support\Manager` — Payment's drivers implement several independent
capability interfaces at once, not one uniform contract). Built-ins (`OfflinePaymentDriver`
as `'offline'`, `StripePaymentDriver` as `'stripe'`) registered in
`PaymentServiceProvider::boot()`, exactly how `Shipping::extend('acs', ...)` already works.
- **Breaking:** `Modules\Core\Checkout\Services\CheckoutService::getPaymentMethods()` now returns
`Illuminate\Support\Collection<int, PaymentMethod>` (ordered by `position`), not
`array<string>`. A method is offered only once three independent checks all pass — `enabled`
(admin turned it on), `driver_missing_at` is null (the driver class still exists), and the
resolved driver's own `Configurable::isConfigured()` (its runtime requirements are met) — each
failure meaning something different to an admin diagnosing why a method isn't showing up.
`initiatePayment()` resolves the driver via the selected row's own `driver` column, not `type`.
- `Modules\Core\Payment\Filament\Resources\PaymentMethodResource` — `canCreate()`/`canDelete()`
now both `true` (previously hardcoded `false`, since a row could only ever be a config-defined
type before this release). New create/edit form (`name`, `type`, `driver` — a `Select`
populated live from `PaymentDriverRegistry::all()`, `capture_mode`, `captured_status`,
`authorized_status`); reorderable table (`->reorderable('position')`); a distinct "Driver
status" icon column (separate from the `enabled` toggle) showing whether `driver_missing_at`
is set.
- `Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus` reads `captured_status`/
`authorized_status` off the `PaymentMethod` row (`where('type', $event->type)`) instead of
`config(...)`.
- `Modules\Core\Command\InstallLunarCommand::seedPaymentMethods()` no longer iterates
`config('lunar.payments.types')` — it seeds exactly one opinionated `cash-on-delivery` starter
row, every value a plain literal in the command itself (not sourced from config or the
registry — a driver has no business carrying opinions about what its captured order status
should be called; that's a merchant decision). Skip-if-exists, same as before.
### Added
- `php artisan boboko:payment:sync-drivers` — reconciles every `PaymentMethod` row's `driver`
against `PaymentDriverRegistry`, setting `driver_missing_at` when a driver no longer resolves
(a package removed, a custom `register()` call deleted) and clearing it automatically if that
driver is registered again in a later deploy. Deliberately its own standalone command, meant to
run unconditionally on every container start/deploy (Dockerfile entrypoint, alongside
`migrate`) — "did the set of registered drivers change" is a deploy-time event, cheap enough to
check every time regardless of whether anything actually changed. Verified live: flags a row
whose `driver` was manually corrupted, and auto-clears the flag once the driver resolves again.
- `docs/payments.md` — new "Registry, DB instance, and cross-cutting config" section: the
three-layer split researched against `Shipping`'s own already-existing pattern and three real
e-commerce platforms (Shopify, WooCommerce, Medusa.js), the "would a store ever plausibly want
two different answers to this" test for deciding config vs. DB-column placement, and the
three-check availability chain.
- `Modules\Core\Payment\Services\PaymentMethodCache` (`Cache::rememberForever`, same pattern as
`Localization\Services\LanguageCache`) + `Modules\Core\Payment\Services\PaymentMethodService`
(`create`/`update`/`delete`/`list`) — the single read/write gateway for `PaymentMethod` now used
by every Filament resource action (create, edit, edit-fee, delete, the inline `enabled` toggle)
instead of the Eloquent model directly, so the cache is invalidated and
`PaymentMethodCreated`/`PaymentMethodUpdated`/`PaymentMethodDeleted`/`PaymentMethodsReordered`
dispatch on every write, with no exceptions other than Filament's own drag-to-reorder (which
does a raw bulk SQL `UPDATE` on the position column directly via
`CanReorderRecords`/`reorderTable()`, before `afterReordering()` fires — a confirmed, unavoidable
Filament limitation; the reorder hook only clears the cache and dispatches
`PaymentMethodsReordered` afterward). `CheckoutService::getPaymentMethods()` and
`ApplyResolvedPaymentStatus` both now read through the cache instead of querying `PaymentMethod`
directly.
- `Modules\Core\Payment\Models\CoreTransaction` (a `Lunar\Models\Transaction` subclass) +
`Modules\Core\Payment\Support\TransactionDriverAdapter`, registered via
`Lunar\Facades\ModelManifest::replace(Lunar\Models\Contracts\Transaction::class,
CoreTransaction::class)` — the same contract-swap mechanism already used elsewhere for
`Customer`/`Staff`. Fixes a real crash (`InvalidArgumentException: Driver [cash-on-delivery] not
supported`) the first time anything called `$transaction->refund()`/`->capture()`:
`Lunar\Models\Transaction::driver()` calls Lunar's own, entirely separate
`Lunar\Facades\Payments::driver()` manager, which had never heard of any of this codebase's
driver keys. `CoreTransaction::driver()` returns `TransactionDriverAdapter` instead, which
resolves the transaction's real `PaymentMethod`/`PaymentDriverRegistry` driver and calls it —
Lunar's own admin panel "Refund"/"Capture" buttons now transparently reach the real payment
system underneath, including correctly reporting failure (not a silently-faked success) when
the resolved driver doesn't implement `SupportsRefunds`/`SupportsCaptures`.
- `TransactionDriverAdapter::refundVia(Transaction $transaction, ?string $driverKey, int $amount,
?string $notes = null)` — refund through an explicitly chosen driver, independent of the one
the original payment went through (e.g. a cash-on-delivery order refunded via Bank Transfer,
which has no notion of the original offline payment at all). The order page's refund action
gained a "Refund via" `Select` (every `PaymentDriverRegistry` driver implementing
`SupportsRefunds`, defaulting to the transaction's own driver) that routes through this method
instead of `Lunar\Models\Transaction::refund()`, whose fixed signature has no room for a driver
override.
- `Modules\Core\Payment\Drivers\BankTransferPaymentDriver` (registered as `'bank-transfer'`) —
manual/attested, same trust model as `OfflinePaymentDriver`: no gateway call, `pay()`/`refund()`
decide success immediately on a staff member's say-so. Implements both `SupportsPay` and
`SupportsRefunds`; exists specifically so a payment taken through a different method can still
be refunded via bank transfer. The admin UI for receiving a payment this way (bank reference,
notes, proof-of-transfer upload) is a follow-up — the driver itself is complete and usable via
the registry today.
- `Modules\Core\Order\Filament\Infolists\TransactionEntry` (swapped in for Lunar's own
`Lunar\Admin\Support\Infolists\Components\Transaction` via a new
`OrderTransactionsExtension::extendTransactionsRepeatableEntry()` hook) — the order page's
transaction cards now also show a note recorded in `Transaction.meta['notes']` when the `notes`
column itself is empty. `Order\Services\TransactionRecorder` only ever wrote `notes` from
`PaymentResult::$failureReason`, which is never set on a successful result — a manual driver's
staff-entered note (e.g. `BankTransferPaymentDriver`'s) was being recorded but had nowhere to
render.
- `Modules\Core\Payment\Listeners\LogPaymentMethodActivity` — `PaymentMethod` now has an admin
activity trail, unlike `Order`/`Transaction`/`Staff` it previously had none. Routes
`PaymentMethodCreated`/`Updated`/`Deleted` through the existing `Logging\ActivityLogService`
(the same one `Localization\Listeners\LogTranslationActivity` already uses) rather than adding
`PaymentMethod` to `Lunar\Base\Traits\LogsActivity`'s generic model-observer logging —
`PaymentMethodUpdated::$old`/`PaymentMethodDeleted::$method`'s snapshot already carry more
deliberate before/after context than Eloquent's own dirty-attribute diffing would reconstruct.
`PaymentMethodsReordered` is deliberately NOT logged — a multi-row position change doesn't fit
`ActivityLogService`'s one-`Model`-subject shape, and isn't worth a new method for a low-stakes,
purely-cosmetic setting.
- New `payment_methods.refunded_status` column + form field (same `Select` pattern as
`captured_status`/`authorized_status`) — `ApplyResolvedPaymentStatus` now also reacts to
`PaymentRefunded`, so `Order.status` actually changes on a refund; before this, only the
*derived* `Order::paymentStatus()` reflected a refund (reading `transactions` live), while the
stored `status` column — what admin filtering, customer emails, etc. actually key off — never
moved. Resolves the ORIGINAL payment method for this lookup, not the refund event's own
`$type`: a refund routed through a different driver via `refundVia()` (e.g. cash-on-delivery
refunded through Bank Transfer) carries the REFUND driver's registry key as `$event->type`,
which usually isn't even a real `PaymentMethod.type` — the listener now finds the order's
earliest successful `capture`/`intent` transaction instead and reads `refunded_status` off
*that* transaction's own `PaymentMethod` row, since that's the payment the refund is actually
reversing. Deliberately no `void_status` yet — a void never moved money, so it doesn't carry
the same "the customer needs to see this changed" weight a refund does.
### Fixed
- Existing `PaymentMethod` rows seeded before this release (`cash-on-delivery`, `cash-in-hand`)
had `driver`/`capture_mode`/`captured_status` all `NULL` after the migration ran — a data
backfill was required (not automated by the migration itself) to restore them to a resolvable
state; flagged here since a consuming app upgrading past this release needs the same backfill
for its own pre-existing rows before `getPaymentMethods()` will offer them again.
- `Lunar\Admin\Filament\Resources\OrderResource\Pages\ManageOrder::getRefundAction()`/
`getCaptureAction()` and `OrderItemsTable::getBulkRefundAction()` report a failed refund/capture
by calling `$action->failureNotification(...)`, `$action->failure()`, then `$action->halt()` —
but `Filament\Actions\Concerns\InteractsWithActions::callMountedAction()` only ever sends that
notification from a code path that runs after the action's closure returns normally; `halt()`
throws `Filament\Support\Exceptions\Halt`, caught by an earlier `catch` block that rolls back and
returns, so the notification was built but never sent — clicking "Refund" on a payment method
that genuinely can't be refunded looked like nothing happened at all, no error, no toast. Real,
pre-existing Filament/Lunar bug, invisible until this release's `TransactionDriverAdapter` made
an honest failure (rather than a hard crash or a silently-faked success) actually reachable.
Fixed via new `Modules\Core\Order\Filament\Extensions\OrderRefundActionsExtension`/
`OrderItemsTableExtension`, which wrap the affected actions' closures to send the queued failure
notification themselves before re-throwing `Halt`.
- `TransactionDriverAdapter::refund()`/`capture()` never included `order_id` in the `$context`
passed to the driver, so `Order\Listeners\RecordPaymentTransaction`/`ApplyResolvedPaymentStatus`
(both requiring `$context['order_id']`) silently no-op'd for every admin-initiated refund/capture
through any driver — no audit `Transaction` row was ever created, regardless of whether the
refund/capture itself succeeded. Fixed by passing `$transaction->order_id` through.
## [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
+4 -2
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.16.3",
"autoload": { "autoload": {
"psr-4": { "psr-4": {
"Modules\\Core\\": "src/" "Modules\\Core\\": "src/"
@@ -37,12 +37,14 @@
"Modules\\Core\\Providers\\CoreServiceProvider", "Modules\\Core\\Providers\\CoreServiceProvider",
"Modules\\Core\\Providers\\AuthServiceProvider", "Modules\\Core\\Providers\\AuthServiceProvider",
"Modules\\Core\\Providers\\CustomerServiceProvider", "Modules\\Core\\Providers\\CustomerServiceProvider",
"Modules\\Core\\Providers\\CheckoutServiceProvider",
"Modules\\Core\\Providers\\PaymentServiceProvider", "Modules\\Core\\Providers\\PaymentServiceProvider",
"Modules\\Core\\Providers\\LocalizationServiceProvider", "Modules\\Core\\Providers\\LocalizationServiceProvider",
"Modules\\Core\\Providers\\CatalogServiceProvider", "Modules\\Core\\Providers\\CatalogServiceProvider",
"Modules\\Core\\Providers\\CartServiceProvider", "Modules\\Core\\Providers\\CartServiceProvider",
"Modules\\Core\\Providers\\ReviewServiceProvider", "Modules\\Core\\Providers\\ReviewServiceProvider",
"Modules\\Core\\Providers\\ShippingServiceProvider" "Modules\\Core\\Providers\\ShippingServiceProvider",
"Modules\\Core\\Providers\\OrderServiceProvider"
] ]
} }
}, },
+21
View File
@@ -0,0 +1,21 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Policy versions
|--------------------------------------------------------------------------
|
| Plain version strings, bumped by whoever edits the corresponding legal
| page — recorded alongside every consent/acceptance so a later dispute
| ("what did the shopper actually agree to?") can be answered from the
| order/cart itself rather than a live lookup against whatever the pages
| say TODAY. Not tied to any CMS/database row on purpose — this stays a
| plain config value the same way payment.php's cart_pipeline is a plain
| cross-cutting setting, not a per-instance one.
|
*/
'privacy_policy_version' => env('LEGAL_PRIVACY_POLICY_VERSION', '2026-01-01'),
'terms_version' => env('LEGAL_TERMS_VERSION', '2026-01-01'),
];
+13 -31
View File
@@ -1,46 +1,28 @@
<?php <?php
use Modules\Core\Payment\Drivers\OfflinePaymentDriver; use Modules\Core\Payment\Pipelines\Cart\ApplyPaymentMethodFee;
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
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| |
| Appended to config('lunar.cart.pipelines.cart') after ApplyShipping so | Appended to config('lunar.cart.pipelines.cart') after ApplyShipping so
| the cash-on-delivery fee is added to the shipping total before the | the selected payment method's own fee (if any) is added to the
| final Calculate step sums everything up. | shipping total before the 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, ApplyPaymentMethodFee::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.
--- ---
+450
View File
@@ -0,0 +1,450 @@
<title>Order Feature Survey</title>
<style>
:root {
--paper: #FAFAF7;
--ink: #1C1C1A;
--muted: #6B6B63;
--accent: #2F5D50;
--accent-soft: #E4EDE9;
--good: #3F7A5C;
--good-soft: #E6F0EA;
--warn: #B8863B;
--warn-soft: #F5ECDC;
--miss: #A14B3B;
--miss-soft: #F5E5E0;
--hairline: #E4E2DB;
--card: #FFFFFF;
}
:root:not([data-theme="light"]) {
@media (prefers-color-scheme: dark) {
--paper: #17181A;
--ink: #EDEBE4;
--muted: #9B9A90;
--accent: #7FBFA8;
--accent-soft: #1E2C27;
--good: #6FBF97;
--good-soft: #1B2A22;
--warn: #D9A85C;
--warn-soft: #2C2418;
--miss: #D97C68;
--miss-soft: #2E1E1A;
--hairline: #2C2D2E;
--card: #1E1F21;
}
}
:root[data-theme="dark"] {
--paper: #17181A;
--ink: #EDEBE4;
--muted: #9B9A90;
--accent: #7FBFA8;
--accent-soft: #1E2C27;
--good: #6FBF97;
--good-soft: #1B2A22;
--warn: #D9A85C;
--warn-soft: #2C2418;
--miss: #D97C68;
--miss-soft: #2E1E1A;
--hairline: #2C2D2E;
--card: #1E1F21;
}
* { box-sizing: border-box; }
body {
background: var(--paper);
color: var(--ink);
font-family: "IBM Plex Sans", ui-sans-serif, system-ui, sans-serif;
font-size: 15.5px;
line-height: 1.55;
margin: 0;
padding: 4.5rem 1.5rem 6rem;
}
.wrap {
max-width: 780px;
margin: 0 auto;
}
header.page {
margin-bottom: 3.25rem;
}
.eyebrow {
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 0.72rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--accent);
margin-bottom: 0.9rem;
}
h1 {
font-family: "Fraunces", Georgia, serif;
font-weight: 560;
font-size: clamp(2.1rem, 4.5vw, 2.65rem);
line-height: 1.08;
letter-spacing: -0.01em;
margin: 0 0 0.9rem;
text-wrap: balance;
}
.dek {
color: var(--muted);
max-width: 60ch;
font-size: 1.02rem;
}
.dek strong {
color: var(--ink);
font-weight: 600;
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
margin-top: 1.6rem;
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 0.72rem;
letter-spacing: 0.04em;
padding: 0.28rem 0.6rem;
border-radius: 3px;
}
.chip.have { background: var(--good-soft); color: var(--good); }
.chip.partial { background: var(--warn-soft); color: var(--warn); }
.chip.missing { background: var(--miss-soft); color: var(--miss); }
section.category {
margin-top: 3rem;
}
.cat-head {
display: flex;
align-items: baseline;
gap: 0.85rem;
border-bottom: 1px solid var(--hairline);
padding-bottom: 0.7rem;
margin-bottom: 1.1rem;
}
.cat-num {
font-family: "Fraunces", Georgia, serif;
font-size: 1.05rem;
color: var(--accent);
font-variant-numeric: tabular-nums;
min-width: 1.6rem;
}
.cat-head h2 {
font-family: "Fraunces", Georgia, serif;
font-weight: 500;
font-size: 1.28rem;
margin: 0;
letter-spacing: -0.005em;
}
.cat-note {
color: var(--muted);
font-size: 0.86rem;
margin: 0 0 1.2rem;
max-width: 62ch;
}
.feature {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.3rem 1rem;
padding: 1.05rem 0;
border-bottom: 1px solid var(--hairline);
align-items: start;
}
.feature:last-child { border-bottom: none; }
.f-name {
font-weight: 600;
font-size: 0.98rem;
}
.f-status {
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 0.68rem;
letter-spacing: 0.06em;
text-transform: uppercase;
padding: 0.22rem 0.55rem;
border-radius: 3px;
white-space: nowrap;
height: fit-content;
}
.f-status.have { background: var(--good-soft); color: var(--good); }
.f-status.partial { background: var(--warn-soft); color: var(--warn); }
.f-status.missing { background: var(--miss-soft); color: var(--miss); }
.f-note {
grid-column: 1 / -1;
color: var(--muted);
font-size: 0.87rem;
margin-top: 0.15rem;
max-width: 66ch;
}
.f-note code {
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 0.82em;
background: var(--accent-soft);
color: var(--accent);
padding: 0.08em 0.35em;
border-radius: 3px;
}
footer.page {
margin-top: 4rem;
padding-top: 1.5rem;
border-top: 1px solid var(--hairline);
color: var(--muted);
font-size: 0.82rem;
display: flex;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
footer.page a { color: var(--accent); }
@media (max-width: 560px) {
body { padding: 3rem 1.1rem 4rem; }
.feature { grid-template-columns: 1fr; }
.f-status { justify-self: start; }
}
</style>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400..600&family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap">
<div class="wrap">
<header class="page">
<div class="eyebrow">boboko / order · competitive survey</div>
<h1>What order management elsewhere can do that boboko can't yet</h1>
<p class="dek">
Where the Checkout survey stopped — the instant <code>Order</code> exists — this
one starts. A feature-by-feature pass across Shopify, WooCommerce, PrestaShop, and
(briefly) Magento's post-placement order layer — sourced, not recalled from memory —
checked against what <strong>Lunar's <code>Order</code> model and the
already-shipped Filament <code>ManageOrder</code> page</strong> actually support
today. For deciding what the new <code>Order</code> module needs to own, not a
build order.
</p>
<div class="legend">
<span class="chip have">● have</span>
<span class="chip partial">◐ partial</span>
<span class="chip missing">○ missing</span>
</div>
</header>
<section class="category">
<div class="cat-head">
<span class="cat-num">01</span>
<h2>Status model</h2>
</div>
<p class="cat-note">One field, or several axes — and who's allowed to move it.</p>
<div class="feature">
<div class="f-name">Payment status independent of a single overall status</div>
<span class="f-status partial">partial</span>
<div class="f-note">The data exists — <code>ManageOrder::paymentStatus()</code> derives a real value from <code>transactions()</code>/<code>captureTotal()</code>/<code>refundTotal()</code>/<code>intentTotal()</code> — but it's a computed display value on the admin page, not a stored column or something the rest of the system (mailers, automations) can key off. Shopify and Magento both make payment status a first-class, independently-queryable dimension; here it's derived on the fly, once, in one Filament page.</div>
</div>
<div class="feature">
<div class="f-name">Fulfillment status independent of overall status</div>
<span class="f-status missing">missing</span>
<div class="f-note">No equivalent of <code>paymentStatus()</code> exists for shipment/fulfillment state — <code>Order</code> has no <code>shipments()</code> relation of its own at all; it's added dynamically by <code>Modules\Core\Shipping\Providers\ShippingServiceProvider::resolveRelationUsing()</code>, outside Order's own boundary (see docs/checkout.md, "Where Order would likely absorb work"). Every platform researched (Shopify, Woo, PrestaShop, Magento) treats "has this shipped" as derivable from child records, not a manually-set field — Lunar has the child records (<code>Shipment</code>) but no derived status method reading them.</div>
</div>
<div class="feature">
<div class="f-name">Staff-editable order status with a picker/action</div>
<span class="f-status have">have</span>
<div class="f-note"><code>ManageOrder</code> ships a working <code>UpdateStatusAction</code> out of the box, backed by <code>config('lunar.orders.statuses')</code> — a flat, merchant-configured list, each entry carrying a <code>label</code>/<code>color</code>/<code>favourite</code> flag. Closer to WooCommerce's single linear field than Shopify's multi-axis split.</div>
</div>
<div class="feature">
<div class="f-name">Status rows carry behavior (auto-send email, generate invoice, restock)</div>
<span class="f-status partial">partial</span>
<div class="f-note">Each status entry in <code>config('lunar.orders.statuses')</code> already declares <code>mailers</code> and <code>notifications</code> arrays — the PrestaShop-style shape is there in config — but per the Checkout survey's finding, nothing in core actually reads and dispatches from those keys on a transition. The data model for "status carries behavior" exists; the behavior doesn't.</div>
</div>
<div class="feature">
<div class="f-name">Order-status-changed event other code can react to</div>
<span class="f-status missing">missing</span>
<div class="f-note">Same gap the Checkout survey flagged for order creation: no <code>OrderStatusUpdated</code>/equivalent exists anywhere in core. <code>UpdateStatusAction</code> just writes the column. Anything wanting to react to a status change — a confirmation email, a webhook, re-deriving payment/fulfillment status — has to hook the raw Eloquent <code>Order::updated()</code> event and diff <code>status</code> itself.</div>
</div>
</section>
<section class="category">
<div class="cat-head">
<span class="cat-num">02</span>
<h2>Fulfillment &amp; shipment tracking</h2>
</div>
<p class="cat-note">Turning a placed order into a package that moves.</p>
<div class="feature">
<div class="f-name">Shipment as its own record, separate from the order</div>
<span class="f-status have">have</span>
<div class="f-note"><code>Modules\Core\Shipping\Models\Shipment</code> (carrier, tracking reference, label-printed timestamp, manifest reference) already exists and belongs to <code>Order</code>. Built this session, ahead of most gaps in this survey — the record shape is closer to Magento's per-shipment entity than Woo's "no shipment entity at all."</div>
</div>
<div class="feature">
<div class="f-name">Multiple shipments per order (partial/split fulfillment)</div>
<span class="f-status partial">partial</span>
<div class="f-note"><code>Shipment</code> has no <code>quantity</code>-per-line or <code>order_line_id</code> concept — it's one shipment record per carrier voucher, with a <code>parent_reference</code> for ACS's own multipart-voucher case (one physical order split into multiple packages by the carrier), not a per-line-item fulfillment split decided by staff. Closer to "multiple packages for one shipment" than Magento's true per-line partial-shipment model.</div>
</div>
<div class="feature">
<div class="f-name">Create-shipment action from the order admin screen</div>
<span class="f-status have">have</span>
<div class="f-note"><code>Modules\Core\Shipping\Extensions\OrderViewExtension</code> adds a working "Create Shipment" header action to <code>ManageOrder</code>, resolving a <code>CarrierFulfillmentInterface</code> by the order's chosen shipping method and calling <code>createShipment()</code> — genuinely wired, not a stub. Currently lives under <code>Shipping</code>, flagged in docs/checkout.md as conceptually an <code>Order</code> concern.</div>
</div>
<div class="feature">
<div class="f-name">Tracking number + carrier surfaced on the order itself</div>
<span class="f-status have">have</span>
<div class="f-note"><code>Shipment.tracking_reference</code>/<code>carrier</code> exist and are populated by <code>createShipment()</code>; <code>PollShipmentTrackingJob</code> (scheduled every 30 minutes) keeps <code>ShipmentInfo</code> checkpoints current via <code>CarrierFulfillmentInterface::trackShipment()</code>. Genuinely ahead of PrestaShop's thin <code>order_carrier.tracking_number</code> field — this has a real checkpoint history, not just one string.</div>
</div>
<div class="feature">
<div class="f-name">"Shipped"/"delivered" status auto-derived from tracking</div>
<span class="f-status missing">missing</span>
<div class="f-note">The tracking checkpoints exist (<code>ShipmentInfo</code>, <code>TrackingStatus</code> enum including <code>Delivered</code>) but nothing writes them back onto <code>Order.status</code> — a delivered shipment doesn't move the order out of whatever status it was already in. Every platform researched treats "delivered" as a status a customer/staff can see on the order, not something buried one relation away.</div>
</div>
<div class="feature">
<div class="f-name">Shipping/delivery notification emails (shipped, out-for-delivery, delivered)</div>
<span class="f-status missing">missing</span>
<div class="f-note">Research: Shopify fires four separate templated notifications across this window alone (shipping confirmation, out-for-delivery, delivered, plus edited-order). None of the pieces exist here — no order-status-changed event (01) to trigger from, and no mailer wired to <code>PollShipmentTrackingJob</code>'s own status updates either.</div>
</div>
</section>
<section class="category">
<div class="cat-head">
<span class="cat-num">03</span>
<h2>Payments: capture, refund, cancellation</h2>
</div>
<p class="cat-note">Money moving back out, and orders that never should have been placed.</p>
<div class="feature">
<div class="f-name">Refund action from the order screen, amount-scoped</div>
<span class="f-status have">have</span>
<div class="f-note"><code>ManageOrder</code>'s <code>refund</code> action already exists — picks a transaction, an amount (validated against <code>availableToRefund()</code>), and notes, then calls the driver's own <code>Transaction::refund()</code>. This is genuinely native, matching Woo/Magento's line-item-adjacent (if not line-item-exact) refund UX.</div>
</div>
<div class="feature">
<div class="f-name">Capture action for auth-then-capture payment flows</div>
<span class="f-status have">have</span>
<div class="f-note"><code>ManageOrder</code>'s <code>capture</code> action + <code>requiresCapture()</code>/<code>canBeRefunded()</code> guard methods already exist, delegating to <code>Transaction::capture()</code> — this is the Stripe "authorize now, capture later" flow's admin-side half, already built ahead of most gaps here.</div>
</div>
<div class="feature">
<div class="f-name">Refund tied to specific line items (not just a dollar amount)</div>
<span class="f-status missing">missing</span>
<div class="f-note">The refund action takes a transaction + amount, with no line-item selection or restock decision — WooCommerce and Magento both make "which items, how many, restock or not" the primary refund UI; here it's one number against one transaction, closer to a manual adjustment than a structured partial return.</div>
</div>
<div class="feature">
<div class="f-name">Order cancellation as a distinct action (vs. just changing status)</div>
<span class="f-status missing">missing</span>
<div class="f-note">No dedicated "cancel" action exists on <code>ManageOrder</code> — a cancellation today would just be picking a "cancelled"-labeled entry from the generic status dropdown (01), with no automatic refund trigger, no stock-release logic, and no distinction from any other manual status edit.</div>
</div>
<div class="feature">
<div class="f-name">Refund/capture reflected back into an order-level payment status</div>
<span class="f-status partial">partial</span>
<div class="f-note">Same gap as 01's payment-status finding — <code>paymentStatus()</code> recomputes correctly from transactions when the admin page loads, but a refund doesn't push the order into a <code>refunded</code>/<code>partially-refunded</code> overall status the way Shopify's <code>displayFinancialStatus</code> does automatically.</div>
</div>
</section>
<section class="category">
<div class="cat-head">
<span class="cat-num">04</span>
<h2>Returns (RMA)</h2>
</div>
<p class="cat-note">The one area every researched platform treats as optional, not core.</p>
<div class="feature">
<div class="f-name">Return-merchandise-authorization flow (customer requests, staff approves)</div>
<span class="f-status missing">missing</span>
<div class="f-note">No <code>Return</code>/RMA model, status set, or request flow exists anywhere in this codebase. Consistent with the research: Shopify is the only platform of the four with this genuinely native; PrestaShop ships it off-by-default; Magento gates it behind the paid Adobe Commerce tier; WooCommerce lacks it entirely. Safe to treat as a real gap, not an urgent one.</div>
</div>
<div class="feature">
<div class="f-name">Return shipping label generation</div>
<span class="f-status missing">missing</span>
<div class="f-note">Depends entirely on the RMA flow above existing first — <code>CarrierFulfillmentInterface</code> already has the label-printing primitive (<code>printLabel()</code>) a return label would reuse, so the carrier-side plumbing isn't the blocker, the RMA request/approval model is.</div>
</div>
</section>
<section class="category">
<div class="cat-head">
<span class="cat-num">05</span>
<h2>Order editing</h2>
</div>
<p class="cat-note">Changing a placed order — and where every platform draws the line.</p>
<div class="feature">
<div class="f-name">Editing guardrails keyed to fulfillment state</div>
<span class="f-status missing">missing</span>
<div class="f-note">No line-item add/remove exists on a placed order at all today (unlike Shopify/Woo/PrestaShop, which all allow it up to some fulfillment-keyed cutoff, then force a return instead) — so there's no guardrail to speak of yet because there's no editing to guard. Whatever gets built here should key the cutoff to <code>Shipment</code> existing, per the pattern all four researched platforms converge on.</div>
</div>
<div class="feature">
<div class="f-name">Editable shipping/billing address after placement</div>
<span class="f-status missing">missing</span>
<div class="f-note"><code>OrderAddress</code> rows are snapshotted at creation (see Checkout survey, 02) and nothing in <code>ManageOrder</code> exposes editing them afterward — every platform researched treats address edits as lower-risk than line-item edits and allows them more freely; this codebase currently allows neither.</div>
</div>
<div class="feature">
<div class="f-name">Tag editing on a placed order</div>
<span class="f-status have">have</span>
<div class="f-note"><code>ManageOrder</code>'s <code>edit_tags</code> action already works — the one piece of native post-placement editing that exists today, via <code>HasTags</code> on the <code>Order</code> model.</div>
</div>
</section>
<section class="category">
<div class="cat-head">
<span class="cat-num">06</span>
<h2>Notes &amp; audit trail</h2>
</div>
<p class="cat-note">The one thing every researched platform treats as non-negotiable.</p>
<div class="feature">
<div class="f-name">Append-only change history (who changed what, when)</div>
<span class="f-status have">have</span>
<div class="f-note"><code>Order</code> already uses Spatie's <code>LogsActivity</code> trait — every save is recorded with a diff, same underlying mechanism already relied on elsewhere in this codebase (staff activity log, translation history). Structurally equivalent to PrestaShop's <code>order_history</code> table, just via a different package.</div>
</div>
<div class="feature">
<div class="f-name">Internal staff notes, separate from system-generated log entries</div>
<span class="f-status missing">missing</span>
<div class="f-note">The activity log above captures field changes automatically, but there's no free-text "leave a note for the next person" field — every platform researched has this as a distinct feed from the automatic history (Woo's Order Notes, Shopify's Timeline comments, Magento's Comments History), usually with a private-vs-customer-visible toggle. Nothing here yet.</div>
</div>
<div class="feature">
<div class="f-name">Customer-visible note-to-customer, sent as a message</div>
<span class="f-status missing">missing</span>
<div class="f-note">Depends on both the internal-notes feature above and a working mailer (01/02) — genuinely blocked on more foundational gaps, not just unbuilt on its own.</div>
</div>
</section>
<footer class="page">
<span>Compiled 2026-09-01 — sources cited inline; <code>vendor/lunarphp/lunar</code> and this codebase's own <code>src/</code> reads are marked by file/class name, Shopify/WooCommerce/PrestaShop/Magento claims are marked "Research."</span>
<span>boboko-core / docs</span>
</footer>
</div>
+3
View File
@@ -2,6 +2,9 @@
Findings from comparing a real Shopify product export CSV against Lunar's schema (`vendor/lunarphp/core`), plus the resulting implementation plan for `MigrateImport\Shopify\ShopifyExportImporter`. Findings from comparing a real Shopify product export CSV against Lunar's schema (`vendor/lunarphp/core`), plus the resulting implementation plan for `MigrateImport\Shopify\ShopifyExportImporter`.
Need to discard everything and re-import from scratch (e.g. after a schema/indexer change that
only applies to newly-created rows)? See `docs/shopify-reimport.md`.
## Idempotency problem ## Idempotency problem
Nothing in Lunar tracks "this record came from external system X, ID Y." Re-running an import with no external-ID tracking would duplicate every product on each run. Nothing in Lunar tracks "this record came from external system X, ID Y." Re-running an import with no external-ID tracking would duplicate every product on each run.
+149
View File
@@ -0,0 +1,149 @@
# Wiping products before a clean Shopify re-import
A runbook for discarding every imported product (and everything that hangs off one —
variants, prices, media, reviews, options/values, the Meilisearch documents) and re-running
`ShopifyExportImporter` from scratch. Useful after a schema/indexer change that only applies to
newly-created rows (see "Why a wipe, not an update" below), or when the export CSV itself changed
enough that stale products need to go, not just be updated in place.
Every command below is a `tinker --execute=` one-liner run inside the app container — adjust the
exec prefix (`./bin/dc-core.sh exec app ...`, `docker compose exec app ...`, etc.) for your setup.
---
## Why a wipe, not an update
`ShopifyExportImporter`'s resolvers are mostly `firstOrCreate` — re-running the importer against
an *existing* database updates matched rows but leaves already-created ones exactly as they were.
That's the right behavior for routine re-imports (an updated price, a new variant), but it means a
change to what gets set **at creation time only** — e.g. `ProductOptionResolver` now also setting
`label`, not just `name`, on a `ProductOption` — never reaches a `ProductOption` row that already
exists. A wipe forces every row to go through creation again, picking up such fixes.
---
## 1. Delete every product
Cascades to `ProductVariant`, prices, and Spatie media rows — verified live (see
`shopify-import.md`'s own history/commit log for context). Also removes each product's Meilisearch
document automatically, via Scout's own delete hook fired on `forceDelete()` — no separate
`scout:flush` needed.
```php
\Lunar\Models\Product::withTrashed()->get()->each->forceDelete();
```
**Let this run to completion.** Interrupting it mid-loop (e.g. Ctrl+C on the tinker session) stops
after whichever product it was on, leaving the rest undeleted — safe to just re-run the same
command again afterward, since already-deleted products are simply skipped.
Verify:
```php
\Lunar\Models\Product::withTrashed()->count(); // 0
```
### Requires: `product_reviews.product_id` cascades on delete
`product_reviews` (boboko-core's own table, not Lunar's) originally had no `ON DELETE` clause on
its `product_id` foreign key — deleting a reviewed product threw a constraint violation instead of
the review going with it. Fixed by
`database/migrations/2026_09_03_000001_add_cascade_delete_to_product_reviews_product_id.php`. Make
sure this migration has actually run (`php artisan migrate`) before step 1, or a product with
reviews will fail to delete.
---
## 2. Delete product options and values
Not touched by step 1 (`ProductOption`/`ProductOptionValue` aren't scoped to one product — they're
shared across the catalog, per `ProductOptionResolver::resolveOption()`'s `shared: true`). Safe to
delete in full once every product (and therefore every variant referencing an option value via the
`product_option_value_product_variant` pivot) is gone — deleting values while variants still
reference them throws the same kind of FK violation step 1 guards against.
```php
\Lunar\Models\ProductOptionValue::query()->delete();
\Lunar\Models\ProductOption::query()->delete();
```
Verify:
```php
\Lunar\Models\ProductOption::count(); // 0
\Lunar\Models\ProductOptionValue::count(); // 0
```
---
## 3. Clear the import mappings
Without this, the importer's `ImportMapping::resolve(...)` calls still find the (now-deleted)
mappings' rows absent, so this step is really about not leaving stale mapping rows pointing at
nothing — `ImportMapping` rows aren't foreign-keyed to the models they map (`morphTo`, no
constraint), so leaving them wouldn't break the re-import, but a stale mapping for a product that
no longer exists is dead weight.
```php
\Modules\Core\MigrateImport\Models\ImportMapping::where('source', 'shopify')->delete();
```
Verify:
```php
\Modules\Core\MigrateImport\Models\ImportMapping::where('source', 'shopify')->count(); // 0
```
---
## 4. Re-run the importer
`boboko:migrate:import` dispatches `RunMigrateImportJob` onto the queue — **not synchronous** —
so a queue worker must actually be running (`php artisan queue:work`, or your dev queue container)
or the job just sits queued.
```bash
php artisan boboko:migrate:import --source=shopify --type=export --file=<absolute path to the CSV>
```
The `--file` value must be an **absolute path** inside the container (e.g.
`/var/www/html/storage/app/private/imports/shopify/products_export.csv`) when running
non-interactively — a path relative to `storage/app/private/imports` only resolves correctly when
the command can fall back to its interactive prompt, which isn't available in a scripted/non-TTY
run.
Watch the queue worker's own log output for `FAIL` entries (see `docs/lunar.md` or your compose
setup for how logs are routed to `docker compose logs`) — a clean run shows every
`Laravel\Scout\Jobs\MakeSearchable` / `Spatie\MediaLibrary\Conversions\Jobs\PerformConversionsJob`
line ending `DONE`, never `FAIL`.
---
## 5. Re-sync Meilisearch and reindex
```bash
php artisan lunar:meilisearch:setup
php artisan lunar:meilisearch:tune-product-search
php artisan lunar:search:index "Lunar\Models\Product" --refresh
```
`--refresh` re-syncs filterable/sortable index settings *and* reindexes every document — it does
not reset `typoTolerance`/`prefixSearch` (confirmed live: both survived a `--refresh` run
unchanged), so `tune-product-search` only needs re-running here for completeness/if it hadn't
already been applied, not because `--refresh` would have clobbered it.
---
## Verifying the result
```php
// Product count should match the CSV's actual unique `Handle` count, not
// whatever the database held before the wipe — those aren't the same number
// if stale/manually-added products existed alongside the CSV-sourced ones.
\Lunar\Models\Product::count();
// Spot-check that at least one variant picked up its own image (see
// shopify-import.md's "Images" section) — 0 is only correct if the CSV
// genuinely has no `Variant Image` values populated.
\Lunar\Models\ProductVariant::has('images')->count();
```
@@ -2,7 +2,7 @@
@if (! $otpSent) @if (! $otpSent)
<form wire:submit="requestOtp"> <form wire:submit="requestOtp">
<div class="grid gap-y-4"> <div style="display: flex; flex-direction: column; row-gap: 1rem;">
<x-filament::input.wrapper> <x-filament::input.wrapper>
<x-filament::input <x-filament::input
type="email" type="email"
@@ -24,7 +24,7 @@
</form> </form>
@else @else
<form wire:submit="authenticate"> <form wire:submit="authenticate">
<div class="grid gap-y-4"> <div style="display: flex; flex-direction: column; row-gap: 1rem;">
<p class="text-sm text-gray-500"> <p class="text-sm text-gray-500">
A login code was sent to <strong>{{ $email }}</strong>. A login code was sent to <strong>{{ $email }}</strong>.
</p> </p>
@@ -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>
@@ -0,0 +1,3 @@
<p>Hi,</p>
<p>Payment of <strong>{{ $amount }}</strong> for your order <strong>{{ $reference }}</strong> has been captured.</p>
@@ -0,0 +1,3 @@
<p>Hi,</p>
<p>Good news — your order <strong>{{ $reference }}</strong> has been delivered.</p>
@@ -0,0 +1,3 @@
<p>Hi,</p>
<p>A refund of <strong>{{ $amount }}</strong> has been issued for your order <strong>{{ $reference }}</strong>.</p>
@@ -0,0 +1,3 @@
<p>Hi,</p>
<p>Your order <strong>{{ $reference }}</strong> is now: <strong>{{ $statusLabel }}</strong></p>
@@ -31,6 +31,13 @@ use Modules\Core\Recovery\Events\CheckoutAbandoned;
* state at all; every cart still matching the query below refires its event * state at all; every cart still matching the query below refires its event
* on every run until Recovery (not yet built — see * on every run until Recovery (not yet built — see
* docs/recovery-strategies.md) owns its own dedup/tracking table. * docs/recovery-strategies.md) owns its own dedup/tracking table.
*
* Both queries require meta->recovery_consent = true — CartAbandoned/
* CheckoutAbandoned exist specifically to drive future recovery-email
* sends (Checkout\Services\CheckoutService::setRecoveryConsent() is where
* that consent is actually recorded), and a non-consenting cart's
* abandonment must never be dispatched at all, not merely filtered later
* at send time — see docs referenced above for the legal reasoning.
*/ */
class DetectAbandonedCarts extends Command class DetectAbandonedCarts extends Command
{ {
@@ -48,6 +55,7 @@ class DetectAbandonedCarts extends Command
Cart::query() Cart::query()
->whereDoesntHave('orders') ->whereDoesntHave('orders')
->where('updated_at', '<=', $cutoff) ->where('updated_at', '<=', $cutoff)
->where('meta->recovery_consent', true)
->with('lines') ->with('lines')
->chunkById(200, function ($carts) use (&$cartsAbandoned) { ->chunkById(200, function ($carts) use (&$cartsAbandoned) {
foreach ($carts as $cart) { foreach ($carts as $cart) {
@@ -64,6 +72,7 @@ class DetectAbandonedCarts extends Command
Cart::query() Cart::query()
->whereHas('orders', fn ($query) => $query->whereNull('placed_at')) ->whereHas('orders', fn ($query) => $query->whereNull('placed_at'))
->where('updated_at', '<=', $cutoff) ->where('updated_at', '<=', $cutoff)
->where('meta->recovery_consent', true)
->with(['orders' => fn ($query) => $query->whereNull('placed_at')]) ->with(['orders' => fn ($query) => $query->whereNull('placed_at')])
->chunkById(200, function ($carts) use (&$checkoutsAbandoned) { ->chunkById(200, function ($carts) use (&$checkoutsAbandoned) {
foreach ($carts as $cart) { foreach ($carts as $cart) {
+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
{ {
@@ -0,0 +1,20 @@
<?php
namespace Modules\Core\Checkout\Events;
use Lunar\Models\Cart;
/**
* Dispatched by CheckoutService::setRecoveryConsent() every time the
* shopper's promotional/abandoned-cart-recovery opt-in changes — including
* an explicit opt-OUT (a later submit with the checkbox unticked), not
* just an opt-in. $consent is the new value, already written to
* Cart::meta by the time this fires.
*/
class RecoveryConsentSet
{
public function __construct(
public readonly Cart $cart,
public readonly bool $consent,
) {}
}
@@ -0,0 +1,19 @@
<?php
namespace Modules\Core\Checkout\Exceptions;
use RuntimeException;
/**
* Thrown by CheckoutService::initiatePayment() when $termsAccepted is
* false — an Order is a consumer contract, and its acceptance must be
* refused rather than created-then-flagged. No Lunar exception type
* covers this, same reasoning as UnknownPaymentTypeException.
*/
class TermsNotAcceptedException extends RuntimeException
{
public function __construct()
{
parent::__construct('The order cannot be placed until the terms have been accepted.');
}
}
+175 -110
View File
@@ -10,17 +10,19 @@ 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\RecoveryConsentSet;
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\TermsNotAcceptedException;
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 +31,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 +45,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
@@ -61,6 +67,49 @@ class CheckoutService
return $cart; return $cart;
} }
/**
* The shopper's promotional/abandoned-cart-recovery opt-in — a
* cart-level decision, deliberately independent of setShippingAddress()/
* setBillingAddress(): consent is given once, and must NOT be reset or
* re-asked just because the shopper later changes which address is on
* the cart (a different Addressable being set is not a withdrawal of
* consent). Only an explicit call to THIS method — the checkbox itself
* being submitted, checked or unchecked — ever changes it; calling it
* again with false is exactly how a later opt-out is recorded.
*
* Stored on Cart::meta (interim, per the legal design this implements —
* a real column/consent record is the eventual target) as
* recovery_consent (bool), recovery_consent_at (ISO 8601 timestamp,
* null when $consent is false), and recovery_consent_policy_version
* (config('legal.privacy_policy_version') at the moment of consent —
* so a later dispute is answered from what was actually agreed to,
* not whatever the policy says today). Separate from any future
* newsletter opt-in — recovery consent is its own scope, never merged
* with marketing-newsletter consent.
*
* Deliberately does not merge with the meta-writing pattern
* selectPaymentMethod() uses (read-merge-save in two separate
* statements) — this writes both meta keys in one save, since there's
* no dependency between recovery_consent and anything else needing to
* be persisted first.
*/
public function setRecoveryConsent(bool $consent): Cart
{
$cart = $this->cart->currentOrCreate();
$cart->meta = [
...($cart->meta?->toArray() ?? []),
'recovery_consent' => $consent,
'recovery_consent_at' => $consent ? now()->toIso8601String() : null,
'recovery_consent_policy_version' => $consent ? config('legal.privacy_policy_version') : null,
];
$cart->save();
Event::dispatch(new RecoveryConsentSet($cart, $consent));
return $cart;
}
/** /**
* Every shipping option currently available for the cart — already * Every shipping option currently available for the cart — already
* fully backed by the merged Shipping-Carriers work: this runs every * fully backed by the merged Shipping-Carriers work: this runs every
@@ -98,98 +147,73 @@ 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();
} }
/** /**
* Records which payment type the shopper picked (Cart::meta * Records which payment type the shopper picked (Cart::meta
* ['payment_method']) — read by e.g. Modules\Core\Payment\Pipelines\ * ['payment_method']) — read by Modules\Core\Payment\Pipelines\
* Cart\ApplyCashOnDeliveryFee to add that type's own cart-total * Cart\ApplyPaymentMethodFee to add that method's own `data.fee` (if
* adjustments before recalculation. * any) before recalculation.
* *
* Also snapshots Cart::fingerprint() into meta, *after* saving the * Also snapshots Cart::fingerprint() into meta, *after* saving the
* chosen type — the fingerprint has to reflect the final total * chosen type — the fingerprint has to reflect the final total
* including any payment-type-specific adjustment (e.g. a COD * including any payment-method-specific fee, which only exists once
* surcharge), which only exists once payment_method is set and the * payment_method is set and the cart recalculates. Captured here,
* cart recalculates. Captured here, server-side, rather than asked of * server-side, rather than asked of the storefront: this is the last
* the storefront: this is the last moment before confirmPayment() that * moment before initiatePayment() that the shopper's reviewed total is
* the shopper's reviewed total is known, and confirmPayment() reads it * known, and initiatePayment() reads it back internally instead of
* back internally instead of taking a fingerprint parameter — a * taking a fingerprint parameter — a storefront should never need to
* storefront should never need to know Cart::fingerprint() exists. * 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::calculate() no-ops if this cart instance was already
$cart->meta = [...$cart->meta->toArray(), 'checkout_fingerprint' => $cart->fingerprint()]; // calculated earlier in the request (Cart::isCalculated()) — which
// it will have been if the shopper switches payment method after
// the checkout page's first render already calculated it. Without
// recalculate() forcing a fresh run, the just-saved payment_method
// (and any fee tied to it, see ApplyPaymentMethodFee) would never
// be reflected — the summary would keep showing whichever method
// was calculated first.
$cart = $cart->recalculate();
$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 +222,92 @@ 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.
*
* $termsAccepted is likewise mandatory, not optional data a caller
* might omit — an Order is a consumer contract, and its acceptance
* must be refused (TermsNotAcceptedException, before createOrder() is
* ever called — the order is never created-then-flagged) rather than
* assumed. $policyVersion is recorded alongside it on the created
* Order's own meta (terms_accepted, terms_accepted_at,
* terms_accepted_policy_version) — the order-level equivalent of
* setRecoveryConsent()'s cart-level record, and the durable audit
* trail for a later "what did the shopper actually agree to"
* dispute. Written directly here (not via a separate event/listener)
* since the Order row this attaches to doesn't exist before
* createOrder() runs, and nothing else needs to react to this
* specific write independently of the order simply existing.
*
* @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 TermsNotAcceptedException if $termsAccepted is false
* @throws FingerprintMismatchException
* @throws CartException
*/ */
public function confirmPayment(string $type, array $data = []): Order public function initiatePayment(string $fingerprint, bool $termsAccepted, string $policyVersion, array $data = []): PaymentResult
{ {
if (! in_array($type, $this->getPaymentMethods(), true)) { if (! $termsAccepted) {
throw new UnknownPaymentTypeException($type); throw new TermsNotAcceptedException;
} }
$cart = $this->cart->currentOrCreate(); $cart = $this->cart->currentOrCreate();
$fingerprint = $cart->meta['checkout_fingerprint'] ?? ''; $cart->checkFingerprint($fingerprint);
return $this->resolvePaymentDriver($type)->confirm($cart, $type, $fingerprint, $data); $type = $cart->meta['payment_method'] ?? null;
} $method = $type !== null ? $this->getPaymentMethods()->firstWhere('type', $type) : null;
/** if ($method === null) {
* Resolves $type's registered PaymentDriver, or null if $type has no throw new UnknownPaymentTypeException((string) $type);
* 'payment_driver' registered in config('lunar.payments.types.<type>') }
* at all — deliberately non-throwing so getPaymentMethods() can filter
* unresolvable types silently rather than treating "not registered"
* as an error condition when just checking availability.
*/
private function resolvePaymentDriver(string $type): ?PaymentDriver
{
$driverClass = config("lunar.payments.types.{$type}.payment_driver");
return $driverClass ? app($driverClass) : null; $order = $cart->createOrder();
$order->meta = [
...($order->meta?->toArray() ?? []),
'terms_accepted' => true,
'terms_accepted_at' => now()->toIso8601String(),
'terms_accepted_policy_version' => $policyVersion,
];
$order->save();
$driver = $this->paymentDrivers->resolve($method->driver);
$context = ['cart_id' => $cart->id, 'order_id' => $order->id];
return $method->capture_mode === 'authorize'
? $driver->authorize($type, $order->total, $data, $context)
: $driver->pay($type, $order->total, $data, $context);
} }
} }
+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 {
@@ -24,6 +24,7 @@ class StorefrontLabels
'nav.account' => ['en' => 'Account', 'el' => 'Λογαριασμός'], 'nav.account' => ['en' => 'Account', 'el' => 'Λογαριασμός'],
'nav.back' => ['en' => 'Back', 'el' => 'Πίσω'], 'nav.back' => ['en' => 'Back', 'el' => 'Πίσω'],
'nav.contact' => ['en' => 'Contact', 'el' => 'Επικοινωνία'], 'nav.contact' => ['en' => 'Contact', 'el' => 'Επικοινωνία'],
'nav.close' => ['en' => 'Close', 'el' => 'Κλείσιμο'],
'cart.empty' => ['en' => 'Your cart is empty', 'el' => 'Το καλάθι σας είναι άδειο'], 'cart.empty' => ['en' => 'Your cart is empty', 'el' => 'Το καλάθι σας είναι άδειο'],
'cart.checkout' => ['en' => 'Checkout', 'el' => 'Ολοκλήρωση Παραγγελίας'], 'cart.checkout' => ['en' => 'Checkout', 'el' => 'Ολοκλήρωση Παραγγελίας'],
'cart.total' => ['en' => 'Total', 'el' => 'Σύνολο'], 'cart.total' => ['en' => 'Total', 'el' => 'Σύνολο'],
@@ -38,6 +39,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 αξιολογήσεις πελατών',
@@ -66,6 +68,7 @@ class StorefrontLabels
'en' => '{0} No products found|{1} Showing :first–:last of :total result|[2,*] Showing :first–:last of :total results', 'en' => '{0} No products found|{1} Showing :first–:last of :total result|[2,*] Showing :first–:last of :total results',
'el' => '{0} Δεν βρέθηκαν προϊόντα|{1} Εμφάνιση :first–:last από :total αποτέλεσμα|[2,*] Εμφάνιση :first–:last από :total αποτελέσματα', 'el' => '{0} Δεν βρέθηκαν προϊόντα|{1} Εμφάνιση :first–:last από :total αποτέλεσμα|[2,*] Εμφάνιση :first–:last από :total αποτελέσματα',
], ],
'shop.all_products' => ['en' => 'All Products', 'el' => 'Όλα τα Προϊόντα'],
'shop.sort_label' => ['en' => 'Sort products', 'el' => 'Ταξινόμηση προϊόντων'], 'shop.sort_label' => ['en' => 'Sort products', 'el' => 'Ταξινόμηση προϊόντων'],
'shop.sort_default' => ['en' => 'Default sorting', 'el' => 'Προεπιλεγμένη ταξινόμηση'], 'shop.sort_default' => ['en' => 'Default sorting', 'el' => 'Προεπιλεγμένη ταξινόμηση'],
'shop.sort_popularity' => ['en' => 'Popularity', 'el' => 'Δημοφιλή'], 'shop.sort_popularity' => ['en' => 'Popularity', 'el' => 'Δημοφιλή'],
@@ -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
+15
View File
@@ -0,0 +1,15 @@
<?php
namespace Modules\Core\Order\Enums;
/**
* Derived from Shipment/ShipmentInfo — no equivalent existed anywhere in
* Lunar or this codebase before OrderStatus::fulfillment().
*/
enum FulfillmentStatus: string
{
case Unfulfilled = 'unfulfilled';
case Shipped = 'shipped';
case PartiallyShipped = 'partially-shipped';
case Delivered = 'delivered';
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace Modules\Core\Order\Enums;
/**
* Same states/logic as Lunar's own ManageOrder::paymentStatus(), which
* only exists as a Filament-page Livewire #[Computed] method — this is
* that same derivation, reusable from anywhere via Order::paymentStatus().
*/
enum PaymentStatus: string
{
case Offline = 'offline';
case Uncaptured = 'uncaptured';
case Captured = 'captured';
case PartialRefund = 'partial-refund';
case Refunded = 'refunded';
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace Modules\Core\Order\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Lunar\Models\Order;
use Lunar\Models\Transaction;
/**
* Dispatched by TransactionObserver::saved() when a Transaction's type
* changes to 'capture' (from 'intent') and succeeds. Unlike refunds,
* Lunar's Stripe driver (StoreCharges) reuses the same Transaction row
* across intent -> capture rather than creating a new one, so this can't
* key off `wasRecentlyCreated` the way OrderRefunded does.
*/
class OrderCaptured
{
use Dispatchable;
public function __construct(
public readonly Order $order,
public readonly Transaction $transaction,
) {}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace Modules\Core\Order\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Lunar\Models\Order;
use Modules\Core\Shipping\Models\ShipmentInfo;
/**
* Dispatched by Order's DeriveOrderDeliveredFromShipment listener, which
* reacts to Shipping's ShipmentStatusUpdatedByCarrier — delivery is a
* tracking checkpoint, not a manual status write, so it never goes through
* OrderStatusUpdated. Order.status itself is left untouched here; this is
* only the signal for delivery notifications and similar reactions.
*/
class OrderDelivered
{
use Dispatchable;
public function __construct(
public readonly Order $order,
public readonly ShipmentInfo $shipmentInfo,
) {}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace Modules\Core\Order\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Lunar\Models\Order;
use Lunar\Models\Transaction;
/**
* Dispatched by TransactionObserver::saved() whenever a successful
* type=refund Transaction row is written — every payment driver (Lunar's
* own StripePaymentType::refund(), or a future boboko-owned driver for a
* provider Lunar doesn't ship) creates a new row for each refund, so
* `created` alone (filtered to type+success) is enough here, unlike
* captures which can reuse an existing row.
*/
class OrderRefunded
{
use Dispatchable;
public function __construct(
public readonly Order $order,
public readonly Transaction $transaction,
) {}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace Modules\Core\Order\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Lunar\Models\Order;
/**
* Dispatched by OrderObserver::updated() whenever an Order's status column
* changes, regardless of what wrote it — Filament's UpdateStatusAction,
* artisan tinker, a future API. Lunar's own UpdatesOrderStatus trait fires
* mailers inline, but only for that one admin action; this event is the
* general-purpose hook everything else (our own mailers, automations,
* derived payment/fulfillment status) should listen to instead.
*/
class OrderStatusUpdated
{
use Dispatchable;
public function __construct(
public readonly Order $order,
public readonly ?string $previousStatus,
public readonly string $newStatus,
) {}
}
@@ -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,31 @@
<?php
namespace Modules\Core\Order\Listeners;
use Modules\Core\Order\Events\OrderDelivered;
use Modules\Core\Shipping\Enums\TrackingStatus;
use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier;
/**
* Translates a carrier tracking checkpoint into OrderDelivered — the event
* OrderDeliveredNotification (via NotificationRegistry) actually listens
* to. Kept separate from the notification itself so the "is this checkpoint
* a delivery" filtering doesn't leak into notification code.
*/
class DeriveOrderDeliveredFromShipment
{
public function handle(ShipmentStatusUpdatedByCarrier $event): void
{
if ($event->shipmentInfo->status !== TrackingStatus::Delivered) {
return;
}
$order = $event->shipmentInfo->shipment->order;
if (! $order) {
return;
}
OrderDelivered::dispatch($order, $event->shipmentInfo);
}
}
@@ -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,50 @@
<?php
namespace Modules\Core\Order\Notifications;
use Illuminate\Notifications\AnonymousNotifiable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Facades\Notification as NotificationFacade;
use Modules\Core\Notification\BaseNotification;
use Modules\Core\Order\Events\OrderCaptured;
class OrderCapturedNotification extends BaseNotification
{
public function __construct(private readonly OrderCaptured $event) {}
public static function getKey(): string
{
return 'order.captured.customer.mail';
}
public static function listensTo(): string
{
return OrderCaptured::class;
}
public function via(object $notifiable): array
{
return ['mail'];
}
public function notifiable(): AnonymousNotifiable
{
$order = $this->event->order;
$email = $order->billingAddress?->contact_email ?? $order->shippingAddress?->contact_email;
return NotificationFacade::route('mail', $email);
}
public function toMail(object $notifiable): MailMessage
{
$order = $this->event->order;
return (new MailMessage)
->subject(__('Payment captured for your order :reference', ['reference' => $order->reference]))
->view('core::order.notifications.captured', [
'reference' => $order->reference,
'amount' => $this->event->transaction->amount->formatted,
]);
}
}
@@ -0,0 +1,49 @@
<?php
namespace Modules\Core\Order\Notifications;
use Illuminate\Notifications\AnonymousNotifiable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Facades\Notification as NotificationFacade;
use Modules\Core\Notification\BaseNotification;
use Modules\Core\Order\Events\OrderDelivered;
class OrderDeliveredNotification extends BaseNotification
{
public function __construct(private readonly OrderDelivered $event) {}
public static function getKey(): string
{
return 'order.delivered.customer.mail';
}
public static function listensTo(): string
{
return OrderDelivered::class;
}
public function via(object $notifiable): array
{
return ['mail'];
}
public function notifiable(): AnonymousNotifiable
{
$order = $this->event->order;
$email = $order->billingAddress?->contact_email ?? $order->shippingAddress?->contact_email;
return NotificationFacade::route('mail', $email);
}
public function toMail(object $notifiable): MailMessage
{
$order = $this->event->order;
return (new MailMessage)
->subject(__('Your order :reference has been delivered', ['reference' => $order->reference]))
->view('core::order.notifications.delivered', [
'reference' => $order->reference,
]);
}
}
@@ -0,0 +1,50 @@
<?php
namespace Modules\Core\Order\Notifications;
use Illuminate\Notifications\AnonymousNotifiable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Facades\Notification as NotificationFacade;
use Modules\Core\Notification\BaseNotification;
use Modules\Core\Order\Events\OrderRefunded;
class OrderRefundedNotification extends BaseNotification
{
public function __construct(private readonly OrderRefunded $event) {}
public static function getKey(): string
{
return 'order.refunded.customer.mail';
}
public static function listensTo(): string
{
return OrderRefunded::class;
}
public function via(object $notifiable): array
{
return ['mail'];
}
public function notifiable(): AnonymousNotifiable
{
$order = $this->event->order;
$email = $order->billingAddress?->contact_email ?? $order->shippingAddress?->contact_email;
return NotificationFacade::route('mail', $email);
}
public function toMail(object $notifiable): MailMessage
{
$order = $this->event->order;
return (new MailMessage)
->subject(__('A refund has been issued for your order :reference', ['reference' => $order->reference]))
->view('core::order.notifications.refunded', [
'reference' => $order->reference,
'amount' => $this->event->transaction->amount->formatted,
]);
}
}
@@ -0,0 +1,50 @@
<?php
namespace Modules\Core\Order\Notifications;
use Illuminate\Notifications\AnonymousNotifiable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Facades\Notification as NotificationFacade;
use Modules\Core\Notification\BaseNotification;
use Modules\Core\Order\Events\OrderStatusUpdated;
class OrderStatusUpdatedNotification extends BaseNotification
{
public function __construct(private readonly OrderStatusUpdated $event) {}
public static function getKey(): string
{
return 'order.status_updated.customer.mail';
}
public static function listensTo(): string
{
return OrderStatusUpdated::class;
}
public function via(object $notifiable): array
{
return ['mail'];
}
public function notifiable(): AnonymousNotifiable
{
$order = $this->event->order;
$email = $order->billingAddress?->contact_email ?? $order->shippingAddress?->contact_email;
return NotificationFacade::route('mail', $email);
}
public function toMail(object $notifiable): MailMessage
{
$order = $this->event->order;
return (new MailMessage)
->subject(__('Your order :reference has been updated', ['reference' => $order->reference]))
->view('core::order.notifications.status-updated', [
'reference' => $order->reference,
'statusLabel' => config("lunar.orders.statuses.{$order->status}.label", $order->status),
]);
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace Modules\Core\Order\Observers;
use Lunar\Models\Order;
use Modules\Core\Order\Events\OrderStatusUpdated;
class OrderObserver
{
public function updated(Order $order): void
{
if (! $order->wasChanged('status')) {
return;
}
OrderStatusUpdated::dispatch(
$order,
$order->getOriginal('status'),
$order->status,
);
}
}
@@ -0,0 +1,27 @@
<?php
namespace Modules\Core\Order\Observers;
use Lunar\Models\Transaction;
use Modules\Core\Order\Events\OrderCaptured;
use Modules\Core\Order\Events\OrderRefunded;
class TransactionObserver
{
public function saved(Transaction $transaction): void
{
if (! $transaction->success) {
return;
}
if ($transaction->type === 'refund' && $transaction->wasRecentlyCreated) {
OrderRefunded::dispatch($transaction->order, $transaction);
return;
}
if ($transaction->type === 'capture' && $transaction->wasChanged('type')) {
OrderCaptured::dispatch($transaction->order, $transaction);
}
}
}
@@ -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,
]);
}
}
+93
View File
@@ -0,0 +1,93 @@
<?php
namespace Modules\Core\Order\Support;
use Lunar\Models\Order;
use Modules\Core\Order\Enums\FulfillmentStatus;
use Modules\Core\Order\Enums\PaymentStatus;
use Modules\Core\Shipping\Enums\TrackingStatus;
/**
* Payment/fulfillment state, derived on read from transactions and
* shipments rather than stored — mirrors the logic Lunar's own
* ManageOrder::paymentStatus() computes as a Livewire #[Computed] method
* (Filament-page-only, not reusable), reimplemented here as a plain,
* queryable value any code can call via Order::macro() in
* OrderServiceProvider.
*/
class OrderStatus
{
public static function payment(Order $order): PaymentStatus
{
$transactions = $order->transactions;
$intentTotal = $transactions
->filter(fn ($t) => $t->type === 'intent' && $t->success)
->sum('amount.value');
$captureTotal = $transactions
->filter(fn ($t) => $t->type === 'capture' && $t->success)
->sum('amount.value');
$refundTotal = $transactions
->filter(fn ($t) => $t->type === 'refund' && $t->success)
->sum('amount.value');
$total = $intentTotal ?: $captureTotal;
if (! $total) {
return PaymentStatus::Offline;
}
if (
($refundTotal && $refundTotal < $total) ||
($captureTotal && $captureTotal < $intentTotal)
) {
return PaymentStatus::PartialRefund;
}
if ($refundTotal >= $total) {
return PaymentStatus::Refunded;
}
if ($captureTotal >= $intentTotal) {
return PaymentStatus::Captured;
}
return PaymentStatus::Uncaptured;
}
/**
* Reads shipments.shipmentInfo if already eager-loaded (the caller's
* job — e.g. Order::with('shipments.shipmentInfo')) and picks the
* latest checkpoint in PHP, instead of Shipment::latestShipmentInfo()'s
* per-shipment query — calling this across a list of orders would
* otherwise be an extra query per shipment.
*/
public static function fulfillment(Order $order): FulfillmentStatus
{
$shipments = $order->shipments->reject(fn ($shipment) => $shipment->cancelled_at !== null);
if ($shipments->isEmpty()) {
return FulfillmentStatus::Unfulfilled;
}
$latestStatuses = $shipments->map(function ($shipment) {
$latest = $shipment->relationLoaded('shipmentInfo')
? $shipment->shipmentInfo->sortByDesc('occurred_at')->first()
: $shipment->latestShipmentInfo();
return $latest?->status ?? TrackingStatus::Pending;
});
if ($latestStatuses->every(fn (TrackingStatus $status) => $status === TrackingStatus::Delivered)) {
return FulfillmentStatus::Delivered;
}
if ($latestStatuses->contains(fn (TrackingStatus $status) => $status === TrackingStatus::Delivered)) {
return FulfillmentStatus::PartiallyShipped;
}
return FulfillmentStatus::Shipped;
}
}
+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;
} }
} }
+352 -76
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,316 @@ 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.'); {
} $params = [
'amount' => StripeManager::toStripeAmount($amount->value, $amount->currency),
'currency' => $amount->currency->code,
'capture_method' => $captureMethod,
'confirm' => true,
];
if (! $paymentIntentModel) { if (isset($data['payment_method'])) {
$paymentIntentModel = StripePaymentIntent::create([ $params['payment_method'] = $data['payment_method'];
'intent_id' => $paymentIntentId, } else {
'cart_id' => $cart->id, $params['automatic_payment_methods'] = ['enabled' => true];
]);
}
$paymentIntentModel->update(['processing_at' => now()]);
$stripe = Stripe::getClient();
$paymentIntent = $stripe->paymentIntents->retrieve($paymentIntentId);
if (! $paymentIntent) {
throw new PaymentNotConfirmedException('Unable to locate payment intent.');
}
$policy = config('lunar.stripe.policy', 'automatic');
if ($paymentIntent->status === PaymentIntent::STATUS_REQUIRES_CAPTURE && $policy === 'automatic') {
$paymentIntent = $stripe->paymentIntents->capture($paymentIntentId);
}
if ($paymentIntent->status !== PaymentIntent::STATUS_SUCCEEDED) {
$paymentIntentModel->update(['status' => $paymentIntent->status]);
throw new PaymentNotConfirmedException(
$paymentIntent->last_payment_error->message ?? "Payment intent status: {$paymentIntent->status}."
);
} }
try { try {
$order = $this->checkout->placeOrder($fingerprint); $paymentIntent = Stripe::getClient()->paymentIntents->create($params);
} catch (DisallowMultipleCartOrdersException|CartException $e) { } catch (ApiErrorException $e) {
throw new PaymentNotConfirmedException($e->getMessage(), previous: $e); return $this->declined($type, $amount, $e, $context, authorizing: $captureMethod === 'manual');
} }
$paymentIntentModel->order_id = $order->id; $this->rememberIntent($paymentIntent, $type, $context);
$paymentIntentModel->status = $paymentIntent->status;
$paymentIntentModel->processed_at = now();
$paymentIntentModel->save();
UpdateOrderFromIntent::execute($order, $paymentIntent); return $this->resultFromIntent($type, $paymentIntent, $amount, $context, authorizing: $captureMethod === 'manual');
}
return $order->refresh(); public function handleCallback(string $reference, array $data, array $context = []): PaymentResult
{
[$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);
}
$intentModel?->update(['status' => $paymentIntent->status]);
$amount = $this->priceFromIntent($paymentIntent);
return $this->resultFromIntent($type, $paymentIntent, $amount, $context, $authorizing);
}
public function capture(string $reference, Price $amount, array $context = []): PaymentResult
{
[$intentModel, $type, $context] = $this->resolveIntentModel($reference, $context);
try {
$paymentIntent = Stripe::getClient()->paymentIntents->capture($reference, [
'amount_to_capture' => StripeManager::toStripeAmount($amount->value, $amount->currency),
]);
} catch (ApiErrorException $e) {
$result = $this->failure($amount, $e, $reference);
PaymentCaptureFailed::dispatch($type, $result, $context);
return $result;
}
$intentModel?->update(['status' => $paymentIntent->status]);
$result = new PaymentResult(
status: $paymentIntent->status === PaymentIntent::STATUS_SUCCEEDED
? PaymentResultStatus::Succeeded
: PaymentResultStatus::Failed,
reference: $paymentIntent->id,
amount: $amount,
raw: $paymentIntent->toArray(),
);
$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,63 @@
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\Contracts\Configurable;
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) drives the "Driver status"
* column, deliberately distinct from `enabled` — an admin needs to tell
* "I turned this off" apart from "this driver isn't usable right now" at
* a glance, not have both look like the same disabled state. That column
* also folds in Configurable::isConfigured() (e.g. Stripe with no API key
* set) — a class-resolves-but-isn't-usable state that CheckoutService::
* getPaymentMethods() filters out identically to a missing driver, so an
* admin needs the same at-a-glance warning for it, not just a silently
* absent checkout option.
*
* `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 +77,30 @@ 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()
->state(fn (PaymentMethod $record) => ! $record->driver_missing_at && static::driverIsConfigured($record->driver))
->trueIcon('heroicon-o-check-circle')
->falseIcon('heroicon-o-exclamation-triangle')
->trueColor('success')
->falseColor('danger')
->tooltip(fn (PaymentMethod $record) => static::driverStatusTooltip($record)),
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 +110,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 +238,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 +247,51 @@ 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 '—';
}
return app(PaymentDriverRegistry::class)->label($key) ?? $key;
} }
public static function canDelete($record = null): bool /**
* False for a missing driver too, since Configurable::isConfigured()
* has nothing to ask in that case — driverStatusTooltip() below is
* what tells the two reasons apart for the admin.
*/
private static function driverIsConfigured(?string $key): bool
{ {
return false; $driver = $key ? app(PaymentDriverRegistry::class)->resolve($key) : null;
if (! $driver instanceof Configurable) {
return false;
}
return $driver->isConfigured();
}
private static function driverStatusTooltip(PaymentMethod $record): string
{
if ($record->driver_missing_at) {
return 'Driver not found as of '.$record->driver_missing_at->diffForHumans();
}
if (! static::driverIsConfigured($record->driver)) {
return 'Driver resolves, but is missing required configuration (e.g. an API key) — it will not be offered at checkout.';
}
return 'Driver resolves correctly and is fully configured.';
} }
} }
@@ -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 * ApplyPaymentMethodFee'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,
]; ];
} }
@@ -1,31 +0,0 @@
<?php
namespace Modules\Core\Payment\Pipelines\Cart;
use Closure;
use Lunar\DataTypes\Price;
use Lunar\Models\Contracts\Cart as CartContract;
use Modules\Core\Payment\Models\PaymentMethod;
final class ApplyCashOnDeliveryFee
{
/**
* Called just before cart totals are calculated.
*
* @param Closure(CartContract): mixed $next
*/
public function handle(CartContract $cart, Closure $next): mixed
{
if (($cart->meta['payment_method'] ?? null) === 'cash-on-delivery') {
$fee = (int) (PaymentMethod::where('type', 'cash-on-delivery')->value('data->fee') ?? 0);
$cart->shippingTotal = new Price(
($cart->shippingTotal?->value ?? 0) + $fee,
$cart->currency,
1
);
}
return $next($cart);
}
}
@@ -0,0 +1,49 @@
<?php
namespace Modules\Core\Payment\Pipelines\Cart;
use Closure;
use Lunar\Base\ValueObjects\Cart\ShippingBreakdownItem;
use Lunar\DataTypes\Price;
use Lunar\Models\Contracts\Cart as CartContract;
use Modules\Core\Payment\Models\PaymentMethod;
final class ApplyPaymentMethodFee
{
/**
* Called just before cart totals are calculated, right after
* Lunar\Pipelines\Cart\ApplyShipping. Generic across every
* Modules\Core\Payment\Models\PaymentMethod row, not just cash on
* delivery — whichever type the shopper picked (Cart::meta
* ['payment_method']), its own `data.fee` (set via the Filament "Edit
* fee" action) is applied if present, no matter its slug/name/driver.
*
* Must add the fee as its own Lunar\Base\ValueObjects\Cart\
* ShippingBreakdownItem on $cart->shippingBreakdown rather than
* bumping $cart->shippingTotal directly — the later Lunar\Pipelines\
* Cart\CalculateTax step unconditionally recomputes shippingTotal
* (and shipping tax) from shippingBreakdown's item sum, so a value
* set only on the plain property is silently discarded before the
* cart finishes calculating.
*
* @param Closure(CartContract): mixed $next
*/
public function handle(CartContract $cart, Closure $next): mixed
{
$type = $cart->meta['payment_method'] ?? null;
if ($type) {
$fee = (int) (PaymentMethod::where('type', $type)->first()?->data['fee'] ?? 0);
if ($fee > 0) {
$cart->shippingBreakdown->items->put('payment-method-fee', new ShippingBreakdownItem(
name: 'Payment method fee',
identifier: 'payment-method-fee',
price: new Price($fee, $cart->currency, 1),
));
}
}
return $next($cart);
}
}
@@ -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));
}
}

Some files were not shown because too many files have changed in this diff Show More