diff --git a/CHANGELOG.md b/CHANGELOG.md index cb28ec6..9cc4416 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,38 +7,190 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] ### Added -- Customer-portal backend groundwork — no routes/controllers/views yet (a storefront-facing UI is - 3dealer's job once a frontend designer picks it up), but the boboko-owned services it needs to - call now exist: - - `Modules\Core\Auth\Services\UserOtpService::validate()` now actually logs the shopper in - (`Auth::login()`, `web` guard) — previously it only returned the `User` model with no session - established and no route/controller anywhere ever called it (the checkout page's "Login" tab - was a disabled placeholder). `Auth::login()` alone is enough to merge/associate any active - guest cart too — it fires `Illuminate\Auth\Events\Login`, which Lunar's own - `Lunar\Listeners\CartSessionAuthListener` (registered unconditionally in core, no opt-in - needed) already reacts to, honoring `config('lunar.cart.auth_policy')` (`'merge'` by default). - An earlier draft of this also called `Cart::associate()` directly from this service — removed - as redundant and actually wrong: it ran a second, separate association with a hardcoded - `'merge'` policy that ignored whatever a consumer had actually set `auth_policy` to. Also fixed - an unbounded brute-force window: a 6-digit code (1M combinations, was guessable for its full - 10-minute expiry with no attempt cap) now invalidates itself after 5 wrong guesses - (`users.otp_attempts`, new column), forcing a fresh code request rather than leaving a live one - guessable indefinitely. - - `Modules\Core\Auth\Events\CustomerLoggedIn` — dispatched on every successful OTP login (new - user or returning), for a storefront to hook into (e.g. post-login redirect, analytics). - - `Modules\Core\Customer\Services\CustomerAccountService` — the storefront-facing "My Account" - API (mirrors `CartService`/`CheckoutService`'s shape): `orders()` (paginated, placed orders - only), `order()`, `addresses()`, `createAddress()`/`updateAddress()`/`deleteAddress()`, - `updateProfile()`. Every method is scoped to the given user's own - `latestCustomer()` — there is no method that accepts a bare order/address id without also - requiring the owning user, so a controller built on top of this can't leak one customer's data - to another by trusting a client-supplied id alone (verified live: a second customer attempting - to read/edit the first's address or order gets `AddressNotFoundException`/ - `OrderNotFoundException`, not the record). + +Customer-portal backend groundwork — no routes/controllers/views yet (a storefront-facing UI is +3dealer's job once a frontend designer picks it up), but the boboko-owned services it needs to +call now exist: + +- `Modules\Core\Auth\Services\UserOtpService::validate()` now actually logs the shopper in + (`Auth::login()`, `web` guard) — previously it only returned the `User` model with no session + established and no route/controller anywhere ever called it (the checkout page's "Login" tab + was a disabled placeholder). `Auth::login()` alone is enough to merge/associate any active + guest cart too — it fires `Illuminate\Auth\Events\Login`, which Lunar's own + `Lunar\Listeners\CartSessionAuthListener` (registered unconditionally in core, no opt-in + needed) already reacts to, honoring `config('lunar.cart.auth_policy')` (`'merge'` by default). + An earlier draft of this also called `Cart::associate()` directly from this service — removed + as redundant and actually wrong: it ran a second, separate association with a hardcoded + `'merge'` policy that ignored whatever a consumer had actually set `auth_policy` to. Also fixed + an unbounded brute-force window: a 6-digit code (1M combinations, was guessable for its full + 10-minute expiry with no attempt cap) now invalidates itself after 5 wrong guesses + (`users.otp_attempts`, new column), forcing a fresh code request rather than leaving a live one + guessable indefinitely. +- `Modules\Core\Auth\Events\CustomerLoggedIn` — dispatched on every successful OTP login (new + user or returning), for a storefront to hook into (e.g. post-login redirect, analytics). +- `Modules\Core\Customer\Services\CustomerAccountService` — the storefront-facing "My Account" + API (mirrors `CartService`/`CheckoutService`'s shape): `orders()` (paginated, placed orders + only), `order()`, `addresses()`, `createAddress()`/`updateAddress()`/`deleteAddress()`, + `updateProfile()`. Every method is scoped to the given user's own `latestCustomer()` — there + is no method that accepts a bare order/address id without also requiring the owning user, so a + controller built on top of this can't leak one customer's data to another by trusting a + client-supplied id alone (verified live: a second customer attempting to read/edit the first's + address or order gets `AddressNotFoundException`/`OrderNotFoundException`, not the record). + +## [0.17.5] - 2026-09-15 + +### Added + +- Greek translations for `Lunar\Models\Country`/`State` reference data (`lang/el/countries.php`, + `lang/el/states.php`), keyed by the exact English spellings Lunar's own installer seeds for + Greece (fetched from `data.lunarphp.io/countries+states.json`). Loaded via + `loadTranslationsFrom()` under the `core::` namespace — a plain lang file, not + `Modules\Core\Localization`'s DB-backed `TranslationService`, since this is fixed reference + data, not admin-editable UI copy. A consuming app's storefront looks these up itself (e.g. + `__('core::countries.'.$country->name)`) — core has no storefront UI of its own to wire this + into. +- `Modules\Core\Order\Filament\Extensions\OrderActionsExtension::fixCaptureAction()` — reroutes + the backoffice "Capture" header action through `Modules\Core\Payment\Support\ +TransactionDriverAdapter::capture()`, the same app-level payment pipeline checkout-time captures + use, instead of vendor Lunar's `Lunar\Models\Transaction::capture()` (which resolved + `Lunar\Facades\Payments`, an entirely separate, unused driver registry, and never dispatched + `Modules\Core\Payment\Events\PaymentCaptured`). +- `Modules\Core\Payment\Drivers\StripePaymentDriver::cardMetaFromIntent()` — extracts card + brand/last-four digits from the Stripe PaymentIntent's `latest_charge`, populated into + `PaymentResult::$meta` and mapped onto `Transaction.card_type`/`last_four` by + `Modules\Core\Order\Services\TransactionRecorder`. Fixes the admin activity log's "Payment of + :amount on card ending :last_four" line rendering with no digits, on both checkout-time and + manual captures. Only applies to transactions recorded after this change. +- `PaymentMethod.name` and `Lunar\Shipping\Models\ShippingMethod.name` are now locale-keyed JSON + columns, rendered in Filament via Lunar's own `Lunar\Admin\Support\Forms\Components\ +TranslatedText` — one input per configured `Language` row, same shape/resolution as + Product/Collection names. Existing plain-string rows are preserved under the store's default + language on migration. `ShippingMethod` has no model cast/`ModelManifest` extension point + available (vendor table, `Contracts\ShippingMethod` exists but is never bound by the package), + so its translation is decoded/encoded at the Filament field boundary and via the new + `Modules\Core\Shipping\Support\ShippingMethodName::resolve()` helper, rather than a model cast. +- `Modules\Core\Shipping\Contracts\DeclaresFulfillmentType` — lets a shipping rate driver declare + whether it fulfils via carrier delivery or in-store pickup as a hardcoded fact about the driver + (`AcsRateDriver`, `BoxNowRateDriver` both declare `'carrier'`), instead of asking a merchant to + also pick "Carrier delivery" on every row regardless of driver. The merchant-facing "Fulfillment + type" Select (`ShippingMethod.data['fulfillment_type']`) now only appears for + table-rate-shipping's generic drivers (flat-rate, ship-by, free-shipping), which are genuinely + ambiguous, and moved next to `charge_by` instead of trailing at the end of the form, + disconnected from the decisions it relates to. `Modules\Core\Shipping\Support\ +FulfillmentType::resolve()`/`isStorePickup()` is the new single source of truth, replacing a + direct `data['fulfillment_type']` read in `Order::isStorePickupOrder()`. + +### Fixed + +- `Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus` never advanced `Order::status` past + `awaiting_payment` on a capture — only `paid`/`paid_at` were written, so a fully captured order + could sit indefinitely at "awaiting payment" until a staff member manually clicked "Update + Status". Now, on `PaymentCaptured` (not `PaymentAuthorized`), `status` advances to the next step + in the order's flow, but only when it's still exactly `awaiting_payment`, so a duplicate/delayed + capture event never regresses an order staff already moved further. +- `Lunar\DataTypes\ShippingOption::$collect` (the flag `docs/checkout.md` documents as the + mechanism for detecting a pickup option at checkout) was never actually set by any shipping rate + driver — `Modules\Core\Shipping\Concerns\ResolvesFixedPricing` now populates it from the same + `FulfillmentType` resolution `Order::isStorePickupOrder()` uses, closing a real gap between + documented and actual behavior. + +### Changed + +- `Modules\Core\Order\Filament\Extensions\OrderRefundActionsExtension` renamed to + `OrderActionsExtension` — the class now fixes both the refund and capture header actions on the + order page, not just refund. +- Removed the `lunarphp/stripe` dependency in favour of depending on `stripe/stripe-php` directly. + `Modules\Core\Payment\Drivers\StripePaymentDriver` had already replaced every bit of Lunar's own + Stripe payment flow (checkout, webhook processing) with its own — all that remained load-bearing + from the package was raw API-client access, amount conversion, and a correlation table, none of + which are Lunar-specific. Added first-party replacements: `Modules\Core\Payment\Support\ +StripeManager`, `Modules\Core\Payment\Models\StripePaymentIntent`, `Modules\Core\Payment\Http\ +Middleware\StripeWebhookMiddleware`, and a first-party copy of the vendor's + `create_stripe_payment_intents_table` migration (guarded with `Schema::hasTable()`). No behavior + change for consuming apps. + +## [0.17.4] - 2026-09-15 + +### Added + +- `boboko:catalog:backfill-skus` — one-off Artisan command to generate a SKU + (`SKU-P{product_id}-V{variant_id}`) for every `Lunar\Models\ProductVariant` left with a `null` + SKU by the earlier Shopify import (the source export's `Variant SKU` column was genuinely blank + for these rows, not an importer mapping bug — see `Modules\MigrateImport\Shopify\ +ShopifyExportImporter`). Only touches variants missing a SKU; `--dry-run` lists what would + change without writing. + +## [0.17.3] - 2026-09-15 + +### Changed + +- Removed the `lunarphp/stripe` dependency in favour of depending on `stripe/stripe-php` directly. + `Modules\Core\Payment\Drivers\StripePaymentDriver` had already replaced every bit of Lunar's own + Stripe payment flow (checkout, webhook processing) with its own — all that remained load-bearing + from the package was raw API-client access, amount conversion, and a correlation table, none of + which are Lunar-specific. Added first-party replacements: `Modules\Core\Payment\Support\ +StripeManager` (API client + `toStripeAmount()`/`fromStripeAmount()`), `Modules\Core\Payment\ +Models\StripePaymentIntent` (now with a proper `context` array cast, replacing manual + `json_encode`/`json_decode`), and `Modules\Core\Payment\Http\Middleware\ +StripeWebhookMiddleware`. Added `database/migrations/..._create_stripe_payment_intents_table.php`, + a first-party copy of the vendor migration (guarded with `Schema::hasTable()` so it's a no-op on + any environment that already has the table from the vendor package's own earlier migration run, + and only actually creates it on a genuinely fresh install). No behavior change for consuming + apps — same table, same driver contract, same webhook endpoint. + +## [0.17.2] - 2026-09-15 + +### Fixed + +- `Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus` never advanced `Order::status` past + `awaiting_payment` on a capture — only `paid`/`paid_at` were written, so a fully captured order + could sit indefinitely at "awaiting payment" until a staff member manually clicked "Update + Status". Now, on `PaymentCaptured` (not `PaymentAuthorized` — an authorization isn't yet + captured funds), `status` advances to the next step in the order's flow + (`Modules\Core\Order\Services\OrderStatusFlow::nextOptions()`) — but only when it's still + exactly `awaiting_payment`, so a duplicate/delayed capture event never regresses an order staff + already moved further. +- The backoffice "Capture" action on the order page (Filament) called vendor Lunar's + `Lunar\Models\Transaction::capture()` directly, which resolves `Lunar\Facades\Payments` — an + entirely separate, unused driver registry — and never dispatched `Modules\Core\Payment\Events\ +PaymentCaptured`. This meant a manual capture from the admin panel never ran this app's own + payment pipeline at all (including the status-advance fix above). `Modules\Core\Order\Filament\ +Extensions\OrderActionsExtension` (renamed from `OrderRefundActionsExtension`, since it now + fixes both the refund and capture header actions — see below) now routes capture through + `Modules\Core\Payment\Support\TransactionDriverAdapter::capture()`, the same app-level path + checkout-time captures use. +- `Modules\Core\Payment\Drivers\StripePaymentDriver` never extracted a card's brand/last four + digits from Stripe's response, so `Lunar\Models\Transaction::card_type`/`last_four` were always + empty and the admin's "Payment of :amount on card ending :last_four" activity-log line rendered + with no digits — reproduced on both checkout-time and manual captures. Added + `cardMetaFromIntent()`, reading `payment_method_details` off the PaymentIntent's `latest_charge` + (same source `lunarphp/stripe`'s own `StoreCharges` uses), populated into `PaymentResult::$meta` + from `resultFromIntent()` and `capture()`. `Modules\Core\Order\Services\TransactionRecorder` + now maps `meta['card_type']`/`meta['last_four']` onto the `Transaction` row. Only applies to + transactions recorded after this change — existing rows are not backfilled. + +### Changed + +- `Modules\Core\Order\Filament\Extensions\OrderRefundActionsExtension` renamed to + `OrderActionsExtension` — the class now fixes both the refund and capture header actions on the + order page, not just refund, so the old name undersold its scope. + +## [0.17.1] - 2026-09-15 + +### Fixed + +- `Modules\Core\Payment\Drivers\StripePaymentDriver::createAndConfirm()` only set + `automatic_payment_methods` when no `payment_method` was given — the actual checkout flow always + sends one, so it was omitted, and Stripe fell back to whatever payment methods are enabled in the + Dashboard and demanded a `return_url` on confirm. Fixed by setting `automatic_payment_methods` + unconditionally with `allow_redirects: never` — the storefront's Payment Element already restricts + itself to `paymentMethodTypes: ['card']`, so this just tells Stripe the same thing server-side, + which drops the `return_url` requirement. ## [0.17.0] - 2026-09-14 ### Added + - `Modules\Core\Order\Notifications\OrderPlacedNotification` — an order confirmation email, registered against `Modules\Core\Checkout\Events\OrderPlaced` (fires exactly once per order, regardless of `capture_mode`/driver). Previously only a Stripe (auto-captured) order triggered @@ -54,7 +206,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). `always`/`backorder` variants are deliberately left alone (their stock has no purchasing consequence, decrementing it would just make the column an inaccurate negative number). Also re-triggers Scout reindexing for every affected product, closing the gap `Modules\Core\Catalog\ - Services\ProductIndexer`'s own docblock flagged ("nothing currently reindexes a product when an +Services\ProductIndexer`'s own docblock flagged ("nothing currently reindexes a product when an order decrements its stock") — the search index's `in_stock` filter now reflects the change immediately rather than only on the next scheduled reindex. - `Modules\Core\Cart\Services\CartLifecycleService` — the single source of truth for the four @@ -99,12 +251,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). courier might not reconcile cash for weeks after an order is already marked completed. ### Changed + - **Order status model, redesigned from scratch.** `Order.status` is a single column again (a same-session 3-axis `payment_status`/`fulfillment_status`/`return_status` design was built, then abandoned before shipping — three independent selects let staff set any combination with no cross-field validation, and didn't map onto how staff actually think about an order: one linear journey, not three simultaneous dials). Now driven by `Modules\Core\Order\Services\ - OrderStatusFlow`, a pure transition-table service offering exactly two sequences — carrier and +OrderStatusFlow`, a pure transition-table service offering exactly two sequences — carrier and store-pickup (`Order::isStorePickupOrder()`) — never four; payment method (prepaid vs. COD) affects `Order::paid` only, not which sequence an order follows or where it sits in it. The Filament order page's several guided buttons are replaced by three header actions: "Update @@ -153,14 +306,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). redirect a parcel to a different locker than the one the customer picked at checkout; it's only editable for the (current, checkout-UI-less) case where nothing set it yet. - New "Shipments" section on the order page (`Modules\Core\Shipping\Extensions\ - OrderShipmentsExtension`, between Transactions and Timeline) — "Create Shipment" previously had no +OrderShipmentsExtension`, between Transactions and Timeline) — "Create Shipment" previously had no counterpart anywhere to actually see what it created. One entry per `Shipment` record (a multi-box Box Now order shows one entry per parcel), rendered as two inline-labelled lines — carrier + tracking reference, then status + a "Created … · Locker …" helper line — rather than a grid of individually stacked label/value blocks, which reads as a wall of repeated labels once the admin's main content area narrows below Filament's own grid breakpoint (1024px, common with the sidebar open). Two actions per shipment: "Print Label" and "Cancel". Also added `Modules\Core\Shipping\ - Http\Controllers\DownloadShipmentLabelController` (short-lived signed URL, same auth model as +Http\Controllers\DownloadShipmentLabelController` (short-lived signed URL, same auth model as Lunar's own vendor order-PDF download) — the only other place that called `CarrierFulfillmentInterface::printLabel()` (`ManagePickupManifests`' bulk "Print" action) discarded the returned bytes entirely; this is the first place in the codebase that actually @@ -173,16 +326,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). plain `int $shipment` and looking the record up directly in the controller. - `Modules\Core\Shipping\Enums\TrackingStatus::Failed` — previously unused — is now wired to the new `delivery_failed` status via `Modules\Core\Order\Listeners\ - MarkDeliveryFailedOnCarrierCheckpoint`, from which staff can retry dispatch or convert to a +MarkDeliveryFailedOnCarrierCheckpoint`, from which staff can retry dispatch or convert to a return. - Fixed a separate, unrelated bug hit while testing the above: `Lunar\Shipping\Models\ - ShippingMethod::macro('isStorePickup', ...)` silently never registered — `Lunar\Base\Traits\ - HasModelExtending::__callStatic()` (used by every `Lunar\Base\BaseModel` subclass that doesn't - declare its own `macro()`, `ShippingMethod` included) intercepts *every* unmatched static call +ShippingMethod::macro('isStorePickup', ...)` silently never registered — `Lunar\Base\Traits\ +HasModelExtending::__callStatic()` (used by every `Lunar\Base\BaseModel` subclass that doesn't + declare its own `macro()`, `ShippingMethod` included) intercepts _every_ unmatched static call and dispatches it as an instance call instead of forwarding to `Macroable`, so `hasMacro()` always returned `false` and every order was silently treated as carrier-fulfilled — including store-pickup ones. `Order::isStorePickupOrder()` (the only caller) now reads `ShippingMethod.data - ['fulfillment_type']` directly instead of going through the broken macro. +['fulfillment_type']` directly instead of going through the broken macro. - `CartResource::getEloquentQuery()` no longer filters to carts with a known `user_id`/ `customer_id` — every cart is now listed, guest carts included. Reverses an earlier deliberate exclusion (an anonymous cart has nothing a staff member could click into — no name, no email), @@ -231,7 +384,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). broke the relation manager's Livewire component mount, surfacing as a CSRF-token 419 redirect loop specifically on `/boboko/manifests/{id}`. - "Create Shipment"'s ACS branch gained a "Number of packages" field (`ShipmentRequest:: - $packageCount`, already plumbed through to ACS's `Item_Quantity`/`persistMultipartVouchers()` but +$packageCount`, already plumbed through to ACS's `Item_Quantity`/`persistMultipartVouchers()` but never exposed in the form) — more than 1 issues a main voucher plus a multi-part sub-voucher per extra package, each its own `Shipment` row sharing the same total weight. The existing weight field was relabeled "Total weight (kg)" to make explicit that ACS bills by one total shipment @@ -240,6 +393,7 @@ 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 @@ -248,7 +402,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). `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 + 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 @@ -279,7 +433,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). `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* + 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. @@ -287,6 +441,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [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 @@ -303,16 +458,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [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. +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 @@ -328,9 +485,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). `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 +$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 + _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, @@ -351,6 +508,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [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 @@ -360,13 +518,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). (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 +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`. Deliberately +$driverClass)`/`resolve(string $key): ?object`/`all(): array`. Deliberately knows nothing about `PaymentMethod` or the database (mirrors `Lunar\Shipping\Managers\ - ShippingManager`'s built-in-methods + `Manager::extend()` split, purpose-built rather than +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 @@ -395,6 +553,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). 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 @@ -425,9 +584,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - `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 +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()`: +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 @@ -436,7 +595,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). 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 +?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 @@ -471,18 +630,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - 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 + _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 + _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 @@ -510,15 +670,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [0.14.0] - 2026-09-03 ### Changed + - **Breaking:** `Modules\Core\Catalog\Services\ProductSearchService::search()` now returns `Modules\Core\Catalog\DTOs\ProductListingResult` — the exact same shape `ProductService::list()` already returns — instead of a bare `Illuminate\Database\Eloquent\Collection` 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`. @@ -527,6 +690,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - `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. @@ -536,22 +700,26 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - `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. @@ -559,12 +727,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [0.12.0] - 2026-09-03 ### Changed + - **Breaking:** `Modules\Core\Catalog\Services\ProductService::list()` now returns `Modules\Core\Catalog\DTOs\ProductListingResult` (`->products`: the same `Illuminate\Pagination\LengthAwarePaginator` as before, `->priceBounds`: a new `Modules\Core\Catalog\DTOs\PriceSliderBounds`) instead of returning the paginator directly. A caller doing `$service->list(...)->items()`/`->through(...)` must update to `$service->list(...)->products->items()`/`->through(...)`. This collapses what used to be two separate calls a controller had to orchestrate itself (`list()` for products, `priceRange()` + manual floor/ceil/"is this actually filtered" math for the slider) into one. - **Breaking:** `Modules\Core\Catalog\Services\ProductSearchService::search()`'s signature changed from `search(string $query, ?string $locale = null)` to `search(string $query, ?ProductFilters $filters = null, ?ProductSort $sort = null)` — the `$locale` parameter is gone (see "every configured language, always" below); `$filters`/`$sort` apply the same `Modules\Core\Catalog\Support\ProductFilterBuilder`/`ProductSort::toMeilisearchSort()` semantics `ProductService::list()` already used, so a text search can now be narrowed by price/brand/stock and sorted the same way a category listing can. - `ProductSearchService` now targets every configured store language's fields on every search (`Lunar\Models\Language::all()`), not just the current request locale plus the store's default language. The old `{current, default}` pairing silently stopped catching anything outside those two locales whenever they were equal (a single-language store, or a shopper browsing in the default language) — always searching every configured language closes that gap in both directions. See `docs/product-search.md`. - Extracted `Modules\Core\Catalog\Services\ProductService`'s private `buildFilter()` into a new standalone `Modules\Core\Catalog\Support\ProductFilterBuilder`, so `ProductSearchService` can apply the exact same Meilisearch filter-clause semantics to a text query, instead of reimplementing filter-building a second time. ### Added + - `Modules\Core\Catalog\Services\ProductService::priceSliderBounds()` — `priceRange()` rounded to whole currency units (floor/ceil) plus whether the given selected min/max actually narrows it, returned as a `PriceSliderBounds` DTO. Used internally by `list()` now; also callable directly for a caller (e.g. a text-search results page) that needs slider bounds without a full `list()` call. - `Modules\Core\Catalog\Services\ProductService::priceRange()` gained an optional `string $query = ''` parameter, so a caller can scope the price range to a text search's own matches (pass the shopper's search text) instead of always spanning the whole catalog. - `Modules\Core\Catalog\Services\ProductService::random(int $limit)` — random products still scoped to the Meilisearch index's own channel/status visibility, unlike a raw `Product::inRandomOrder()` (which has no notion of that filtering). Meilisearch has no `ORDER BY RANDOM()` equivalent, so this fetches every matching id only (`attributesToRetrieve: ['id']`), shuffles in PHP, then fetches the full localized documents for just the ids picked, restoring the shuffled order afterward (Meilisearch's `id IN [...]` filter doesn't preserve list order on its own). @@ -575,11 +745,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [0.11.1] - 2026-09-01 ### Fixed + - `Modules\Core\Catalog\Services\RecommendationService::recommend()` built its result with the base `Illuminate\Support\Collection` (`collect()`) instead of `Illuminate\Database\Eloquent\Collection`, even though every element is a `Product` model. `ProductIndexer::toSearchableArray()` calling `->load(['media', 'variants.prices'])` on that result threw `BadMethodCallException: Method Illuminate\Support\Collection::load does not exist` — silently failing every `MakeSearchable` queue job for a saved product (visible only as `FAIL` in the queue log, with the real exception in `storage/logs/laravel.log`). Fixed by having `RecommendationService` accumulate into a real `Eloquent\Collection` from the start. ## [0.11.0] - 2026-09-01 ### Added + - `Modules\Core\Catalog\Services\RecommendationService` — computes "related products" for a given product as a configurable, ordered chain of strategies (`config('catalog.recommendation_rules')`), not one hardcoded rule. Tops up from each successive rule until the limit (default 4) is reached or every rule is exhausted — e.g. 3 products from a same-category rule plus 1 from a random fallback — deduplicated across rules so the same product is never returned twice. Ships with `Modules\Core\Catalog\Recommendations\SameCategoryRule` (other products sharing the source product's first collection) and `RandomRule` (the universal fallback, placed last in the default chain). A new rule is just a class implementing `Modules\Core\Catalog\Contracts\RecommendationRule`. Documented in `docs/product-recommendations.md`. - `Modules\Core\Catalog\Services\ProductIndexer` embeds the result directly into each product's own Meilisearch document as `recommendations: [{id, name, price, image}, ...]` (`recommendations.id` filterable) — a product detail page renders its "related products" section with zero extra queries, same reasoning as the existing `collections` field. Deliberately embeds an `id` for the view to build a locale-correct URL from, not a resolved `href` — `product.show` is locale-prefixed, so a URL baked in at index time would only be correct for whichever locale happened to be active during that index run. - `Modules\Core\Catalog\Events\ProductSaved`/`ProductDeleted`, dispatched from `Product::saved()`/`Product::deleted()` in `CatalogServiceProvider` (the latter fires for both a soft delete and a force delete, matching Scout's own `unsearchable()` trigger point) — feed `Modules\Core\Catalog\Listeners\ReindexProductsRecommendingProduct`, which reverse-searches Meilisearch for every product currently recommending the changed/deleted one (`recommendations.id = "..."` — there's no Postgres relation for this, a recommendation only exists inside the index) and re-indexes them via Scout's own `->searchable()`. Product creation is deliberately not hooked into this: a new product not yet appearing as a recommendation elsewhere is an accepted staleness window, the same tradeoff already documented for `in_stock`/`price` — see `docs/product-recommendations.md`. @@ -588,28 +760,33 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [0.10.1] - 2026-09-01 ### Added + - `Modules\Core\Localization\Services\StorefrontLabels::all()` gains three keys found missing from `3dealer`'s actual `storefront.*` translation usage: `shop.price_min`, `shop.price_max`, `shop.reset` (the price-filter sidebar's min/max labels and its reset link). Picked up by `InstallLunarCommand`'s existing per-key upsert — re-running `lunar:install` on an already-installed store adds only these three rows, leaving everything already seeded or admin-edited untouched. ## [0.10.0] - 2026-08-31 ### Changed + - **Breaking:** Upgraded `lunarphp/lunar`, `lunarphp/core`, `lunarphp/stripe`, `lunarphp/table-rate-shipping`, and `lunarphp/search` to `1.5.0`, and `filament/filament` to `v4.12.6` — the first Filament v4 admin panel on this codebase. `lunarphp/filament3-2fa` and `kalnoy/nestedset` are gone, replaced by Filament v4's native two-factor auth and `lunarphp/nestedset`. Ran Filament's automated `filament-v4` migration tool across `src/`, then hand-fixed three bugs it introduced or left behind: a stale `$infolist` variable reference in `CartResource`'s `ViewCart` page (the parameter had been renamed to `$schema` but the body wasn't updated), `ShippingMethodResourceExtension` rewritten to call `getDefaultChildComponents()` (returns `array|Schema`) instead of the type-safe `getChildComponents()` (always `array`), and — unrelated to the tool, but surfaced by the same PHP version bump — `InvalidCouponException`'s `readonly $code` property illegally shadowing the built-in `Exception::$code`, renamed to `$couponCode`. `LunarStaff::addActivitylogExcept()` updated for the renamed `two_factor_secret`/`two_factor_recovery_codes` staff columns (now `app_authentication_secret`/`app_authentication_recovery_codes`; `two_factor_confirmed_at` removed). Consuming apps must run `composer update boboko/core --with-all-dependencies` and `php artisan migrate`. ### Added + - `Modules\Core\Checkout\Contracts\PaymentDriver` — the abstraction every payment provider implements: `confirm(Cart $cart, string $type, string $fingerprint, array $data): Order` and `isConfigured(): bool`. A driver only ever calls `CheckoutService::placeOrder()` once it has, by whatever mechanism is native to that gateway, independently confirmed payment — never Lunar's raw `Cart::createOrder()`. 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 gets created. - `Modules\Core\Payment\Drivers\OfflinePaymentDriver` — shared by every payment type with no real gateway to confirm against (`cash-in-hand`, `cash-on-delivery`): places the order immediately via `CheckoutService::placeOrder()`, then sets the order status from `config("lunar.payments.types.{$type}.authorized")` using the type actually confirmed, not a hardcoded key, since one driver instance serves multiple types. - `Modules\Core\Payment\Drivers\StripePaymentDriver` — a fork, not a decoration, of `lunarphp/stripe`'s `StripePaymentType::authorize()`: that method is `final` and calls `Cart::createOrder()` directly with no seam to redirect into our fingerprint-checked `placeOrder()`, so this class reimplements its logic (intent retrieval, capture-on-policy, status mapping via `UpdateOrderFromIntent`) with that one substitution. Throws the new `Modules\Core\Payment\Exceptions\PaymentNotConfirmedException` on anything short of a genuinely confirmed payment intent — never falls through to placing an order on ambiguity. - `CheckoutService::getPaymentMethods(): array` — 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 driver reports `isConfigured()` (e.g. Stripe with no API key set is never offered, regardless of the enabled toggle). `selectPaymentMethod(string $type)` and `confirmPayment(string $type, array $data)` both validate against this list, throwing the new `UnknownPaymentTypeException` for a type that isn't currently offered — re-checked in `confirmPayment()` too, since a type could be disabled between selection and confirmation. -- `CheckoutService::selectPaymentMethod()` snapshots `Cart::fingerprint()` into `cart->meta['checkout_fingerprint']` *after* saving the chosen type and recalculating — the fingerprint has to reflect the final total including any payment-type-specific adjustment (e.g. a COD surcharge), which only exists once `payment_method` is set. `confirmPayment()` reads this stored fingerprint internally rather than taking one as a parameter: a storefront should never need to know `Cart::fingerprint()` exists or capture it at exactly the right moment itself. +- `CheckoutService::selectPaymentMethod()` snapshots `Cart::fingerprint()` into `cart->meta['checkout_fingerprint']` _after_ saving the chosen type and recalculating — the fingerprint has to reflect the final total including any payment-type-specific adjustment (e.g. a COD surcharge), which only exists once `payment_method` is set. `confirmPayment()` reads this stored fingerprint internally rather than taking one as a parameter: a storefront should never need to know `Cart::fingerprint()` exists or capture it at exactly the right moment itself. - `Modules\Core\Payment\Models\PaymentMethod` — one DB row per payment type key (matching `config('lunar.payments.types')`), `enabled` boolean plus a `data` jsonb column (starting with `fee`, the flat cash-on-delivery surcharge) — mirrors Lunar's own `Discount` model (a single jsonb column of keyed settings, not a fixed column per setting or a separate conditions table). Seeded idempotently by `InstallLunarCommand` (skip-if-exists per type, safe to re-run after installing a new payment-provider package), always `enabled: false` — a newly-seeded type shouldn't go live for shoppers before staff have configured and reviewed it. Admin-editable via the new `PaymentMethodResource` (inline enabled toggle, modal fee editor) under Settings. - `ApplyCashOnDeliveryFee` now reads its surcharge from `PaymentMethod` instead of static config, so it's admin-editable without a deploy. ### Fixed + - `CashOnDeliveryPaymentDriver` renamed to `OfflinePaymentDriver` and generalized to work for any offline-style type — it previously hardcoded `'cash-on-delivery'` when reading the post-placement order status from config, which would have silently read the wrong type's status the moment a second offline type (`cash-in-hand`) used it. ## [0.9.0] - 2026-08-29 ### Added + - `Modules\Core\Cart\Services\CartService` — the boboko-owned API for all cart mutation, wrapping Lunar's `CartSession`/`Cart` primitives: `addLine()`, `updateLine()`, `removeLine()`, `clear()`, `applyCoupon()`/`removeCoupon()` (throws `InvalidCouponException` on an invalid code), and save-for-later (`saveForLater()`/`moveToCart()`/`activeLines()`/`savedLines()`, backed by a `meta.saved_for_later` flag and a new `Modules\Core\Cart\Pipelines\ZeroSavedForLaterPrice` cart-line pipeline step that zeroes a saved line's price so it's excluded from cart totals without being removed). Dispatches 8 real domain events (`CartLineAdded`/`Updated`/`Removed`/`Saved`/`MovedToCart`, `CartCleared`, `CartCouponApplied`/`Removed`) — none have a listener yet, built so a future concern (analytics, recovery) has something to attach to. Documented in `docs/cart.md`. - `Modules\Core\Checkout\Services\CheckoutService` — the boboko-owned API for the checkout stage (address → shipping selection → order placement), sitting between `CartService` and `Order`: `setShippingAddress()`/`setBillingAddress()`, `getShippingOptions()`/`selectShippingOption()` (throws the new `InvalidShippingOptionException` on an identifier that doesn't resolve — previously a silent no-op), and `placeOrder(string $fingerprint)` (the fingerprint is mandatory, not optional — forces re-confirmation via Lunar's own `FingerprintMismatchException` if the cart changed since the shopper last saw its total). Dispatches `ShippingAddressSet`/`BillingAddressSet`/`ShippingOptionSelected`/`OrderPlaced`, each carrying richer, already-resolved payload (e.g. the resolved `ShippingOption`, not just its identifier) than `CartService`'s events. No exception wrapping otherwise — Lunar's own `CartException`/`FingerprintMismatchException` are already the right shape for a storefront to render as form errors. Documented in `docs/checkout.md`. - `Modules\Core\Cart\Filament\Resources\CartResource`'s list view now classifies every cart into one of four states — **Ongoing**, **Abandoned Cart**, **Abandoned Checkout**, **Completed** — instead of the previous two-tab Abandoned/Completed split, distinguishing a cart that never reached checkout from one that has a started-but-unplaced order (mirrors the real distinction in Lunar's own `Cart::scopeActive()`). Abandonment threshold is a fixed, configurable cutoff (`config('core.cart.abandoned_after')`, default 1 hour). Added a customer hyperlink (list column + a "View Customer" header action on the view page, both pointing straight at `customers/{id}` via the plain `customer_id` column, no extra query via the `customer` relation). @@ -619,17 +796,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - `AcsRateDriver::resolveLivePrice()` now falls back to the rate's own configured static price if the live ACS API call fails (previously: the shipping option silently disappeared from the list on any API error, including a brief outage). `ManageShippingRates` (our Filament subclass of the vendor rates page) now allows a static price to be configured and saved on a "live" rate specifically for this fallback — previously those fields were hidden and discarded on save for any live-priced rate. ### Fixed + - Fixed a crash (`Attempt to read property "price" on null`) opening/editing a live-priced shipping rate with no fallback price configured yet — the vendor `ManageShippingRates` page's `afterStateHydrated` callback for the price field had no null-guard for a rate with zero `basePrices`, which is now the routine case for an unconfigured live rate. - Fixed the Filament admin panel's home URL (`/boboko/home`) incorrectly resolving to the Shipping module's `ManagePickupManifests` page instead of the Dashboard — Filament falls back to the first item of the first registered navigation group when no explicit `homeUrl()` is set, and `ManagePickupManifests` had no `navigationGroup`/`navigationSort` of its own. Fixed via explicit `navigationGroup = 'Sales'` / `navigationSort = 100`, placing it after Sales in the nav instead of first overall. ## [0.8.0] - 2026-08-27 ### Added + - `Modules\Core\Cart\Filament\Resources\CartResource` gives staff read-only visibility into carts in the Filament admin panel — Lunar ships no cart admin view at all. Scoped to carts with a known `user_id`/`customer_id` (an anonymous guest cart carries no identity staff could act on); list table shows customer/user, line/item counts (via Filament's built-in `->counts()`/`->sum()`, no per-row queries), currency, and last activity. List page has only two tabs, **Abandoned** (default active) and **Completed** — no "All" tab, so the list never runs an unfiltered fetch over the whole table. They key off whether the cart has a **placed** order (`orders.placed_at IS NOT NULL`), not `Cart::completed_at` — that column is declared/cast on the model but never actually written anywhere in Lunar core, so it's not a real signal; "Abandoned" mirrors Lunar's own `Cart::scopeActive()`. `getNavigationBadge()` shows the abandoned-cart count in the sidebar via a single `COUNT(*)` query, no rows loaded. View page runs `$cart->calculate()` once so line/cart totals (plain public properties Lunar never persists) are populated, without paying that cost per row in the list. Documented in `docs/cart.md`. ## [0.7.0] - 2026-08-27 ### Added + - `Modules\Core\Catalog\Services\CollectionService` provides category browsing/nav AND single-collection lookup from Meilisearch, mirroring `ProductService` exactly (`list()`, `getById()`, `getBySlug()`, same locale-resolution logic). `Modules\Core\Catalog\Services\CollectionIndexer` extends Lunar's own `Lunar\Search\CollectionIndexer` (which only carried `id`/`name`/`created_at`) to add `parent_id`, `_lft`/`_rgt` (nested-set tree position, filterable/sortable), `collection_group_id`, `slugs`, and `thumbnail`. `Modules\Core\Catalog\DTOs\CollectionFilters` supports `parentId` (children of a specific collection), `groupId`, and `rootOnly` (top-level collections, `parent_id IS NULL` — mutually exclusive with `parentId`). `Modules\Core\Catalog\Enums\CollectionSort` adds `Position` (`_lft:asc`, the recommended default for nav/tree UIs — matches admin arrangement order), `Name`, `Newest`. Must be registered in a consuming app's `config/lunar/search.php` (`Lunar\Models\Collection::class => CollectionIndexer::class`), same as `ProductIndexer`. Documented in `docs/collections.md`. - `Modules\Core\Localization\Services\StorefrontLabels::all()` extracts the default storefront UI label list out of `InstallLunarCommand` into its own class, and adds every previously-missing key (`nav.contact`, `product.description`/`no_image`/`read_more`/`reviews`, `customer_reviews`, `pagination.*`, `review.*`, `shop.*`) that had already been seeded manually in some stores but was absent from the command's own list — bringing the code-side default back in sync with what a real store actually has. `InstallLunarCommand::seedStorefrontLabels()` now does a **per-key upsert** instead of an all-or-nothing "only seed if the group is empty" guard: a key already present in the database (including one an admin has since edited via the Filament **Language Lines** resource) is left untouched, and only missing keys are created via `TranslationService::create()`. This makes it safe to add new keys to `StorefrontLabels::all()` later and re-run `lunar:install` on an already-installed store without either silently skipping the new keys (the old guard's behavior) or reverting an admin's edits back to the hardcoded default. Documented in `docs/localization.md` ("Seeding"). - `Modules\Core\Catalog\Services\CollectionIndexer` adds `ancestors` — `[{id, name}, ...]` ordered root-first (via the newly eager-loaded `ancestors` relation) — so a breadcrumb can render directly from `CollectionService::getById()`/`getBySlug()` with zero extra queries, and `product_count` — how many products are in a collection or any of its descendants, queried from the product Meilisearch index at collection-index time via the same `collection_ids` field `ProductFilters(collectionId:)` filters against. Documented in `docs/collections.md`, including the reindex-ordering gotcha (`product_count` needs the product index reindexed first). @@ -637,6 +817,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - `Modules\Core\Catalog\Services\ProductService::facets(string $field, ?ProductFilters $filters = null): array` returns Meilisearch facet value counts (e.g. `['Brand A' => 48, 'Brand B' => 135]`) for a discrete-value filterable field, scoped to the given filters. Uses Scout's plain `->options(['facets' => [...]])`, merged directly into the raw Meilisearch query the same way `filter`/`sort` already are — no adoption of Lunar's separate `SearchManager`/`Search` facade needed. `ProductService::priceRange(?ProductFilters $filters = null): array{min, max}` covers the numeric-field case `facets()` explicitly doesn't (`price` would otherwise return one "facet" per exact price) — backed by Meilisearch's `facetStats`, not `facetDistribution`. `priceRange()` always excludes `minPrice`/`maxPrice` from the filter it builds (via a new `$exclude` parameter on the private `buildFilter()`), so a price slider's own bounds don't shrink to whatever range is already selected on it; other filters (`collectionId`, `brand`, `inStockOnly`) still apply normally. Documented in `docs/product-listing.md`. ### Changed + - **Breaking:** Renamed the `Product` module to `Catalog`, flattened. Every class under `Modules\Core\Product\*` (`Contracts`, `DTOs`, `Enums`, `Services`, `Observers`, `Filament\Extensions`, `OptionTypes`) now lives under `Modules\Core\Catalog\*` at the same sub-path — e.g. `Modules\Core\Product\Services\ProductService` is now `Modules\Core\Catalog\Services\ProductService`, `Modules\Core\Product\DTOs\ProductFilters` is now `Modules\Core\Catalog\DTOs\ProductFilters`. Class names themselves are unchanged (still `ProductService`, `ProductIndexer`, `ProductFilters`, etc.) — only the namespace/folder moved, to make room for `Collection` as a sibling concern under the same `Catalog` umbrella rather than a disconnected top-level module. Consuming apps must update every `use Modules\Core\Product\...` import and any FQCN reference (`config/lunar/search.php`'s indexer registration, service provider bindings). - **Breaking:** `Modules\Core\Providers\ProductServiceProvider` renamed to `Modules\Core\Providers\CatalogServiceProvider` (composer.json's provider list updated accordingly) — it now only wires `Catalog`-namespace classes (`ProductOptionTypeManager`, `ProductOptionReindexObserver`), so the name follows the same by-concern convention as `LocalizationServiceProvider`/`ReviewServiceProvider`. - **Breaking:** `Modules\Core\Review`'s flat `Extensions/`/`Pages/` folders now nest under `Filament/`, matching the strict per-concern subfolder convention already applied to `Product`(now `Catalog`)/`Localization`. `Modules\Core\Review\Extensions\ProductResourceExtension` is now `Modules\Core\Review\Filament\Extensions\ProductResourceExtension`; `Modules\Core\Review\Pages\ManageProductReviews` is now `Modules\Core\Review\Filament\Pages\ManageProductReviews`. `Modules\Core\Review\Models\ProductReview` is unchanged. @@ -645,29 +826,35 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [0.6.1] - 2026-08-27 ### Added + - `Modules\Core\Product\Contracts\ProductOptionTypeInterface` describes how a category of `Lunar\Models\ProductOption` (e.g. "Color", "Size") behaves — what structured data its values carry in their free-form `meta` jsonb column, and how an admin edits it via Filament — without introducing a new model. Registered via `Modules\Core\Product\Services\ProductOptionTypeManager::get()->register([...])` (a singleton registry, same shape as `Modules\Core\Notification\NotificationRegistry`) from a service provider's `boot()`. An admin then picks one per `ProductOption` from an "Option Type" dropdown on the option's own edit form (added by `Modules\Core\Product\Filament\Extensions\ProductOptionResourceExtension`), stored in `ProductOption::meta['option_type']` — deliberately not tied to the option's `handle`, since a shop's own handle naming shouldn't have to match a type's key. `Modules\Core\Product\Filament\Extensions\ValuesRelationManagerExtension` hooks Lunar's own `ValuesRelationManager` (both extensions via `LunarPanel::extensions()`, registered in `CorePlugin`) to append the resolved type's meta form fields to the stock "Values" tab — no fork of Lunar's classes needed. Ships a reference implementation, `Modules\Core\Product\OptionTypes\ColorOptionType`, registered automatically by the new `Modules\Core\Providers\ProductServiceProvider`. Documented in `docs/product-options.md`. - `Modules\Core\Product\Services\ProductIndexer::mapVariant()` now includes each option's `handle` (alongside its translated name) in a variant's indexed `options[]` — previously only the translated `option`/`value` names and `meta` were indexed, with no stable, locale-independent identifier for which option a value belongs to. -- `Modules\Core\Product\Observers\ProductOptionReindexObserver`, wired in the new `Modules\Core\Providers\ProductServiceProvider`, keeps Meilisearch in sync when a `ProductOption` or `ProductOptionValue` is saved or deleted — e.g. picking an Option Type or editing a color's hex. `ProductIndexer::mapVariant()` embeds each option value's `meta` directly into a product's indexed document, but saving the option/value never fires the *product's* own save events, so without this a changed hex would only reach the index on that product's next unrelated reindex. The observer resolves every `Lunar\Models\Product` whose variants use the changed option (or option value) via the `product_option_value_product_variant` pivot, and calls `->searchable()` on each. +- `Modules\Core\Product\Observers\ProductOptionReindexObserver`, wired in the new `Modules\Core\Providers\ProductServiceProvider`, keeps Meilisearch in sync when a `ProductOption` or `ProductOptionValue` is saved or deleted — e.g. picking an Option Type or editing a color's hex. `ProductIndexer::mapVariant()` embeds each option value's `meta` directly into a product's indexed document, but saving the option/value never fires the _product's_ own save events, so without this a changed hex would only reach the index on that product's next unrelated reindex. The observer resolves every `Lunar\Models\Product` whose variants use the changed option (or option value) via the `product_option_value_product_variant` pivot, and calls `->searchable()` on each. ### Changed + - **Breaking:** `Modules\Core\Product\Services\ProductIndexer`'s indexed `collections` field is now an array of `{id, name}` objects instead of two parallel arrays (`collections` as bare ID strings, `collection_names` as translated names joined only by array index). `collection_names` is removed. Filtering by collection now targets the nested field `collections.id` (Meilisearch supports filtering on nested object fields), not bare `collections` — `Modules\Core\Product\Services\ProductService::buildFilter()` updated accordingly; `ProductFilters(collectionId: ...)`'s public API is unchanged. Run `php artisan lunar:meilisearch:setup` then `lunar:search:index --refresh` after upgrading (see docs/product-listing.md "Gotchas"). - **Breaking:** `ProductIndexer`'s indexed `review_count`/`average_rating` top-level keys are folded into the existing `reviews` key: `reviews` is now `{items, count, average_rating}` instead of a bare array with `review_count`/`average_rating` as separate sibling keys. `reviews` (the array of review items) moved to `reviews.items`. ## [0.6.0] - 2026-08-27 ### Added + - `Modules\Core\Localization\Models\LanguageLine` extends `spatie/laravel-translation-loader`'s `LanguageLine` to fall back to the store's actual default language (`LanguageCache::defaultLocale()`, backed by Lunar's `languages.default` flag) instead of the package's stock behavior of falling back to the static `config('app.fallback_locale')` — the two were previously disconnected, so changing the default language via the Filament **Languages** resource had no effect on which locale an untranslated storefront label silently fell back to. Swapped in automatically via `config('translation-loader.model')` in `LocalizationServiceProvider::register()`; no consuming app changes needed. Documented in `docs/localization.md` ("Fallback locale follows the store's default language"). ### Changed + - **Breaking:** `Modules\Core\Catalog\ProductService::list()` now returns a real `Illuminate\Pagination\LengthAwarePaginator` (built from the localized Meilisearch hits) instead of a plain `array{data, meta}` — gives callers normal Laravel pagination behaviour (`$products->links()`, standard JSON serialization) without ever touching Scout's raw `paginateRaw()` response directly. `getById()`/`getBySlug()` are unaffected (still return `?array`). - `ProductService::withLocalizedFields()` (used by `list()`, `getById()`, `getBySlug()`) no longer hardcodes `name`/`description` as the only translated fields — it now reads every `TranslatedText` attribute on `Product` from `Lunar\Base\AttributeManifest` (the same source Lunar's own indexer reads), so a store's own custom translated attributes (e.g. `seo_title`, `seo_description`) are resolved and locale-stripped automatically with no code change here. Raw `{handle}_{locale}` keys (e.g. `name_el`, `seo_title_en`) are now stripped from every returned product, not just `name_*`/`description_*`. - Extracted `Modules\Core\Localization\Services\LanguageCache` (cached read layer over Lunar's `languages` table: `all()`, `defaultLocale()`, `availableLocales()`, `forget()`) out of `LocaleMiddleware`, which previously owned this as private/static methods despite not being middleware-specific behavior. `LocaleMiddleware` now takes `LanguageCache` via constructor injection. `LocaleMiddleware::defaultLocale()`/`forgetLanguagesCache()` (static) are removed — use `app(LanguageCache::class)` or inject `LanguageCache` directly. ### Fixed + - `Modules\Core\MigrateImport\JudgeMe\Resolvers\ProductResolver::resolve()` picked whichever `lunar_urls` row matched a slug first, which can be a soft-deleted product left behind by an earlier import batch rather than the current live one — a store can easily end up with more than one `Product` row sharing the same slug across re-imports, since a soft-deleted product's URL row isn't cleaned up. This silently broke every downstream lookup for that handle (e.g. `Modules\Core\MigrateImport\JudgeMe\JudgeMeExportImporter` logging "no product found for handle, skipping review" and dropping the row, even though a live product with that exact handle existed). Rewrote as a join against `lunar_products` — via `Product::query()`, so Eloquent's `SoftDeletes` global scope excludes trashed rows — so only a URL pointing at a live product resolves. - `Modules\Core\Review\Models\ProductReview` had no `registerMediaConversions()` at all, unlike `Product`/`ProductVariant` which get one automatically from Lunar's own `Lunar\Base\StandardMediaDefinitions`. `Modules\Core\Search\ProductIndexer::mapMedia()` is shared across product, variant, and review media and always requests the `small` conversion — the first time a review had an attached image, indexing it threw `Spatie\MediaLibrary\MediaCollections\Exceptions\InvalidConversion`, silently failing the product's `MakeSearchable` queue job (and everything queued after it, since Scout batches). Added a matching `small` conversion (300×300, same fit/border/background as Lunar's standard one) directly on `ProductReview`. ### Breaking + - Merged `Modules\Core\Catalog` and `Modules\Core\Search` into a single `Modules\Core\Product` concern, since both existed purely to serve `Product` (browsing/filtering vs. indexing/full-text search — two services, one concern), following a stricter subfolder convention (`Contracts/`, `Enums/`, `Services/`, `DTOs/`, `Models/`, etc. per concern) going forward: - `Modules\Core\Catalog\ProductService` → `Modules\Core\Product\Services\ProductService` - `Modules\Core\Catalog\ProductFilters` → `Modules\Core\Product\DTOs\ProductFilters` @@ -676,6 +863,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - `Modules\Core\Search\ProductSearchService` → `Modules\Core\Product\Services\ProductSearchService` Consuming apps must update any direct references — notably `config/lunar/search.php`'s `'indexers'` map, which points at `ProductIndexer` by FQCN. `Modules\Core\Catalog\ProductOptionTypeInterface` (in-progress, not yet wired to anything) was deliberately left in place rather than moved. + - Reorganized `Modules\Core\Localization` under the same stricter per-concern subfolder convention — `Events/`, `Filament/`, `Listeners/` were already correctly categorized; four loose root files moved into typed buckets by structural role: - `Modules\Core\Localization\LocaleMiddleware` → `Modules\Core\Localization\Middleware\LocaleMiddleware` - `Modules\Core\Localization\LanguageCacheObserver` → `Modules\Core\Localization\Observers\LanguageCacheObserver` @@ -687,26 +875,31 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [0.5.4] - 2026-08-26 ### Added + - `Modules\Core\Catalog\ProductService::list()` accepts a `sort` parameter (new `ProductSort` enum: `PriceAsc`, `PriceDesc`, `Newest`), translated into a Meilisearch `sort` clause — `list()` previously had no way to order results, since it always searches with an empty query string and so has no relevance score to fall back on. `Modules\Core\Search\ProductIndexer::getSortableFields()` now also marks `price` sortable (Lunar's base indexer only marks `created_at`/`updated_at`/`skus`/`status`). Requires re-syncing index settings (`php artisan lunar:meilisearch:setup`) on existing stores. Documented in `docs/product-listing.md` ("Sorting"). ## [0.5.3] - 2026-08-26 ### Fixed + - `Modules\Core\Search\ProductIndexer::toSearchableArray()` threw `column reference "id" is ambiguous` on Postgres when computing `channel_ids` — `$model->channels()->wherePivot('enabled', true)->pluck('id')` joins `lunar_channels` and `lunar_channelables`, both of which have an `id` column, and the unqualified `pluck('id')` left Postgres unable to resolve which table's column to select (SQLite/MySQL tolerated the ambiguity). Qualified as `pluck('lunar_channels.id')`. ## [0.5.2] - 2026-08-26 ### Fixed + - `Modules\Core\Localization\LocaleMiddleware`'s shared view data only ever surfaced a single alternate locale (`altLocale`/`altLocaleUrl`, found via `firstWhere('code', '!=', $current)`) — correct by coincidence for a 2-language store, but silently dropped every locale past the first "other" one found for a 3+ language store, with no error. Replaced with `altLocales`, a collection of every other configured language (`code`, `name`, `url` for the current route each), so a language switcher or `hreflang` tags scale to any number of locales. Documented in `docs/localization.md` ("Shared view data — language switcher and `hreflang` tags"). ## [0.5.1] - 2026-08-25 ### Added + - `Modules\Core\Search\ProductIndexer` now indexes `channel_ids` (filterable) — Lunar's base indexer only marks `status` as filterable, not channel assignment, so storefront search couldn't otherwise scope results to products actually assigned and enabled on the current sales channel. Computed from `$product->channels()->wherePivot('enabled', true)`. Ported from an older `Products` branch whose remote had been deleted; the branch's other, now-superseded `ProductIndexer` changes were dropped in favor of the richer indexer already on `master` (collections, price, variants, reviews — see `0.5.0`). ## [0.5.0] - 2026-08-24 ### Added + - **`Modules\Core\Catalog\ProductService`**: storefront product listing/filtering (`list()`) and single-product lookup (`getById()`, `getBySlug()`), reading directly from the Meilisearch index rather than the database — one data source, no `->get()` model hydration. Returns plain arrays (not Eloquent models), meant to be called directly from a consuming app's controllers. - `ProductFilters` DTO: optional `collectionId`, `brand`, `minPrice`, `maxPrice`, translated into a Meilisearch `filter` expression. - Listing results are locale-aware: `withLocalizedFields()` resolves `name`/`description` from the indexer's per-locale fields, falling back to the store's default language (via `LocaleMiddleware::defaultLocale()`) when the current locale has no translation yet, instead of rendering blank. @@ -717,26 +910,29 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - `docs/lunar.md` "Gotchas": three new entries hit while building this — `ProductOption`/`ProductOptionValue::name` isn't `attribute_data` (so `translateAttribute()` silently returns `null` for it), a running `queue:work` process not picking up an edited Scout indexer class, and Scout's `paginateRaw()->items()` on the Meilisearch driver returning the whole raw response rather than a hit list. ### Fixed + - The admin login form (`Modules\Core\Auth\Filament\Pages\Login`) had no way back from the OTP-entry step to the email step short of reloading the page. A `back()` method resets to the email step; a "← Back" link/button is shown on the OTP step only. ## [0.4.0] - 2026-08-06 ### Added + - **Locale-prefixed routing** (`Modules\Core\Localization\LocaleMiddleware`): a `locale` route-middleware alias, opt-in per shop (not pushed onto the `web` group globally, since admin/Livewire/webhook routes must not be locale-redirected). Reads the first URL segment against Lunar's own `languages` table, sets `App::setLocale()`, and redirects unprefixed/unknown-locale requests to a resolved locale (`Accept-Language` match → default language → first language). Every locale is prefixed, including the default (`/el/...`, `/en/...`), never a bare root — avoids the hreflang/duplicate-content ambiguity of a bare-root default locale. - Language list cached with `Cache::rememberForever()`, invalidated via `Modules\Core\Localization\LanguageCacheObserver` dispatching `LanguageCreated`/`LanguageUpdated`/`LanguageDeleted` events (see below) rather than doing the work itself. - **Language rename safety**: renaming a `Language::code` (e.g. `el` → `gr`) no longer strands existing translations. `MigrateTranslationsForRenamedLanguage` (listening on `LanguageUpdated`) migrates every affected `LanguageLine.text` key from the old code to the new one and flushes both codes' translation caches — closing a real data-loss gap where a rename would otherwise make existing `LanguageLine` translations permanently unreachable. -- **Storefront UI label translations**: pulled in `spatie/laravel-translation-loader` (self-registers via Composer package auto-discovery; its loader *extends* Laravel's file-based `FileLoader` and merges DB translations on top — existing Filament/Lunar vendor `lang/` strings are unaffected). Labels are looked up via Laravel's native `__('storefront.nav.cart')`, kept in its own `storefront` group so nothing collides with Lunar/Filament's own translation groups. +- **Storefront UI label translations**: pulled in `spatie/laravel-translation-loader` (self-registers via Composer package auto-discovery; its loader _extends_ Laravel's file-based `FileLoader` and merges DB translations on top — existing Filament/Lunar vendor `lang/` strings are unaffected). Labels are looked up via Laravel's native `__('storefront.nav.cart')`, kept in its own `storefront` group so nothing collides with Lunar/Filament's own translation groups. - `Modules\Core\Command\InstallLunarCommand` (overriding `lunar:install`) seeds a starter set of ~15 common e-shop labels (`nav.*`, `cart.*`, `product.*`, `auth.*`, `search.*`, English + Greek), idempotently guarded so it's safe on every boot. - `Modules\Core\Localization\TranslationReader::group('storefront')` returns the whole reduced/cached label array for a locale (backed by `LanguageLine`'s own forever-cache) — for sharing to a view as `$labels` or `@json()`-ing to JS, on top of `__()` for single-key Blade lookups. - **Admin UI**: `Modules\Core\Localization\Filament\Resources\LanguageLineResource` (registered in `CorePlugin`) lists/searches/filters `language_lines` and edits each row's `group`, `key`, and one text input per locale currently in `lunar_languages` — locale columns/inputs are generated dynamically from the language list, so a new language needs no resource changes. - **Event-driven writes**: `Modules\Core\Localization\TranslationService` (`create`/`update`/`delete`) is the single write path for `LanguageLine` — the Filament resource's Create/Edit/Delete pages route through it rather than Filament's default direct-model writes. Dispatches `TranslationCreated`/`TranslationUpdated` (carries the full pre-update `{group, key, text}` snapshot, so a bare rename is tracked the same as a text edit)/`TranslationDeleted`, each handled by two listeners: - - `FlushTranslationCache` — closes a real gap in `LanguageLine`'s own self-invalidation, which only flushes locales/groups present *after* a save. Flushes the union of old and new group+locale combinations, so a locale removed from `text`, or a `group`/`key` rename, can't leave a stale cached array behind. + - `FlushTranslationCache` — closes a real gap in `LanguageLine`'s own self-invalidation, which only flushes locales/groups present _after_ a save. Flushes the union of old and new group+locale combinations, so a locale removed from `text`, or a `group`/`key` rename, can't leave a stale cached array behind. - `LogTranslationActivity` — audits every write via the existing `Modules\Core\Logging\ActivityLogService` (`lunar` activity log channel), same `created`/`updated`/`deleted` shape as every other domain write in this project. Properties are flattened with `Arr::dot()` before logging (`text.en`, `text.el` instead of a nested `text` object) since Filament's Activity resource renders `properties` with a flat `KeyValue` field that can't display nested arrays. - `Modules\Core\Providers\LocalizationServiceProvider` — split out of the growing `CoreServiceProvider` (per this project's own "split when a provider does too much" convention) to own all locale/translation middleware, observer, and event-listener registration. ## [0.3.0] - 2026-07-12 ### Added + - **Meilisearch product search**: pulled in `lunarphp/search` (Lunar's driver-agnostic search abstraction — `database`/`meilisearch`/`typesense` engines, selectable via Scout's own `SCOUT_DRIVER` config) and `lunarphp/meilisearch`, wiring Meilisearch in as the search engine for products. - `Search\ProductIndexer` overrides Lunar's own indexer to strip HTML tags from string fields (e.g. `name_en`, `description_en`) before they reach the search index — Lunar's default indexer sends raw attribute HTML straight through, which pollutes relevance ranking and highlighting with markup. - Meilisearch itself is treated as app-level infrastructure, not a `boboko-core` concern: the actual Meilisearch container, host port, and master key live in each consuming app's own `docker-compose.yml`/`.env` (e.g. `3dealer`), the same way Postgres and Valkey do — `boboko-core` only declares the PHP package dependency and the indexing code. @@ -744,17 +940,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [0.2.0] - 2026-07-10 ### Added + - **Product reviews** (`Modules\Core\Review`): a new `ProductReview` model + `product_reviews` table (plain, unprefixed — same convention as `import_mappings`), linked to Lunar's `Product` via a `Product::reviews()` macro (registered in `CorePlugin`, since `Lunar\Models\Product` is a vendor model and can't be edited directly). - **JudgeMe CSV review importer** (`MigrateImport\JudgeMe\JudgeMeExportImporter`), wired into the existing `boboko:migrate:import --source=judgeme --type=export` command: reads a Judge.me review export, resolves each row's `product_handle` to a Lunar product via `Lunar\Models\Url`, and creates/updates `ProductReview` rows idempotently via `import_mappings` (`source=judgeme`, `source_type=review`, keyed on Judge.me's `metaobject_handle`). Rows with no matching product are skipped with a logged warning rather than failing the whole import. - Review images (`picture_urls` in the CSV) are downloaded and stored as real media via Spatie MediaLibrary (`ProductReview::IMAGES_COLLECTION`), not just linked by URL — consistent with how product images are handled. - **Admin UI**: a new "Reviews" sub-navigation page on the product edit screen (`Review\Pages\ManageProductReviews`, wired via `Review\Extensions\ProductResourceExtension`), listing rating/title/reviewer with View, Reply, and Delete actions. The Reply action lets staff write/edit a reply directly from the table, setting `replied_at`. The View modal shows full review detail (body, reviewer email, location, source, dates, reply, downloaded images). ### Fixed + - `Shopify\ShopifyExportImporter` never wrote a Lunar `Url` (slug) row for imported products, despite `docs/shopify-import.md` specifying it should — meaning no code outside the importer itself could resolve "which Lunar product has handle X" (only the importer's own private `import_mappings` bookkeeping could). It now creates/updates a default `Url` row (`slug` = Shopify handle) per product on every import, which the new JudgeMe review importer depends on for product resolution. ## [0.1.0] - 2026-07-09 ### Added + - **Shipping**: registered Lunar's `lunarphp/table-rate-shipping` plugin (`ShippingPlugin`) directly on `CorePlugin`, so table-rate shipping is available to every consumer app without per-app wiring. - **Product migration/import framework** (`Modules\Core\MigrateImport`): a source-agnostic pipeline for importing a vendor's product catalog into Lunar. - `boboko:migrate:import` Artisan command — interactively prompts for source, type (export/API), and credentials or file path, then dispatches the import as a queued job (`RunMigrateImportJob`) on the default queue. The file-path prompt resolves relative to `storage/app/private/imports/`, so answering e.g. `shopify` picks up the first CSV found in `imports/shopify/` automatically. @@ -769,6 +968,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - `CONTRIBUTE.md` — local dev setup (path-repo + `bin/dc-core.sh`), and the manual DB-verification workflow used to build this feature. ### Fixed + - `ProductOptionResolver` created duplicate `ProductOption`/`ProductOptionValue` rows when the same option or value appeared with different casing across products (e.g. Shopify export rows using both "Size" and "size"), and could create a duplicate value within a single product's own variant rows due to relying on a stale lazy-loaded relation. Both now resolve by normalized (slugified) identity queried fresh from the database. - `boboko:migrate:import` could dispatch an import job with a blank file path (silent no-op failure) if the file-path prompt was answered empty; it now re-prompts until a valid, existing file is given. @@ -777,6 +977,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). First release. ### Added + - OTP-based authentication built around `User` instead of `Customer` (`UserOtpService`, `UserOtpMail`), replacing the earlier customer-scoped OTP flow. - `UserCreated` event with a `CreateCustomerForUser` listener to provision a Lunar customer automatically when a user is created. - `UserRelationManager` for managing users from the customer resource in the panel. @@ -787,7 +988,9 @@ First release. - `docs/modules.md` documenting module structure. ### Removed + - `CustomerOtpMail` and `CustomerOtpService`, superseded by the user-based OTP flow. ### Dependencies + - Added explicit `symfony/yaml` requirement (used directly by `Stoic::loadConfig()`). diff --git a/composer.json b/composer.json index eb1881e..f4c2cf6 100644 --- a/composer.json +++ b/composer.json @@ -2,7 +2,7 @@ "name": "boboko/core", "description": "Core module — authentication and shared panel behaviour", "type": "library", - "version": "0.17.0", + "version": "0.17.5", "autoload": { "psr-4": { "Modules\\Core\\": "src/" @@ -18,7 +18,7 @@ "lunarphp/search": "*", "lunarphp/meilisearch": "*", "spatie/laravel-translation-loader": "^2.8", - "lunarphp/stripe": "^1.5" + "stripe/stripe-php": "^16.6" }, "require-dev": { "fakerphp/faker": "^1.23", diff --git a/database/migrations/2026_09_03_000001_create_stripe_payment_intents_table.php b/database/migrations/2026_09_03_000001_create_stripe_payment_intents_table.php new file mode 100644 index 0000000..6f54166 --- /dev/null +++ b/database/migrations/2026_09_03_000001_create_stripe_payment_intents_table.php @@ -0,0 +1,46 @@ +prefix.'stripe_payment_intents')) { + return; + } + + Schema::create($this->prefix.'stripe_payment_intents', function (Blueprint $table) { + $table->id(); + $table->foreignId('cart_id')->constrained($this->prefix.'carts'); + $table->foreignId('order_id')->nullable()->constrained($this->prefix.'orders'); + $table->string('intent_id')->index(); + $table->string('status')->nullable(); + $table->string('event_id')->index()->nullable(); + $table->timestamp('processing_at')->nullable(); + $table->timestamp('processed_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists($this->prefix.'stripe_payment_intents'); + } +}; diff --git a/database/migrations/2026_09_15_000001_make_payment_methods_name_translatable.php b/database/migrations/2026_09_15_000001_make_payment_methods_name_translatable.php new file mode 100644 index 0000000..fc97701 --- /dev/null +++ b/database/migrations/2026_09_15_000001_make_payment_methods_name_translatable.php @@ -0,0 +1,65 @@ +value('code') ?? 'en'; + + $existing = DB::table('payment_methods')->pluck('name', 'id'); + + DB::statement('ALTER TABLE payment_methods ALTER COLUMN name DROP DEFAULT'); + DB::statement("ALTER TABLE payment_methods ALTER COLUMN name TYPE json USING NULL"); + + foreach ($existing as $id => $name) { + if ($name === null) { + continue; + } + + DB::table('payment_methods') + ->where('id', $id) + ->update(['name' => json_encode([$defaultLocale => $name])]); + } + } + + public function down(): void + { + $defaultLocale = Language::where('default', true)->value('code') ?? 'en'; + + $existing = DB::table('payment_methods')->pluck('name', 'id'); + + DB::statement('ALTER TABLE payment_methods ALTER COLUMN name TYPE varchar(255) USING NULL'); + + foreach ($existing as $id => $name) { + $decoded = json_decode((string) $name, true); + $flat = is_array($decoded) ? ($decoded[$defaultLocale] ?? reset($decoded) ?: null) : $name; + + DB::table('payment_methods')->where('id', $id)->update(['name' => $flat]); + } + } +}; diff --git a/database/migrations/2026_09_15_000002_make_shipping_methods_name_translatable.php b/database/migrations/2026_09_15_000002_make_shipping_methods_name_translatable.php new file mode 100644 index 0000000..2a64fdd --- /dev/null +++ b/database/migrations/2026_09_15_000002_make_shipping_methods_name_translatable.php @@ -0,0 +1,74 @@ +prefix.'shipping_methods'; + $defaultLocale = Language::where('default', true)->value('code') ?? 'en'; + + // The column is NOT NULL (vendor migration never marked it + // nullable) — converting via `USING NULL` first, then + // backfilling with a second UPDATE, violates that constraint + // before the backfill ever runs. json_build_object() converts + // each existing string in place, in the same statement, so the + // column is never transiently NULL. $defaultLocale is inlined + // (not bound) — parameter binding inside an ALTER TABLE ... USING + // expression isn't reliable across drivers; it's a Language::code + // value we control, not user input, so quote_literal-safe + // interpolation here is fine. + $quotedLocale = DB::getPdo()->quote($defaultLocale); + + DB::statement("ALTER TABLE {$table} ALTER COLUMN name TYPE json USING json_build_object({$quotedLocale}, name)"); + } + + public function down(): void + { + $table = $this->prefix.'shipping_methods'; + $defaultLocale = Language::where('default', true)->value('code') ?? 'en'; + + // Same NOT NULL constraint applies going back — ->>'{locale}' + // extracts the default locale's text value directly in the + // USING clause, falling back to the first key present via + // COALESCE for any row missing that locale (e.g. one only ever + // filled in via a non-default language). + $quotedLocale = DB::getPdo()->quote($defaultLocale); + + DB::statement( + "ALTER TABLE {$table} ALTER COLUMN name TYPE varchar(255) ". + "USING COALESCE(name->>{$quotedLocale}, (SELECT value FROM json_each_text(name) LIMIT 1))" + ); + } +}; diff --git a/lang/el/countries.php b/lang/el/countries.php new file mode 100644 index 0000000..ead97b9 --- /dev/null +++ b/lang/el/countries.php @@ -0,0 +1,21 @@ +name) — core has no + * storefront UI of its own to wire this into (see docs/lunar.md). + * + * Only Greece is covered — this store operates within Greece; add further + * countries here as needed. + */ +return [ + 'Greece' => 'Ελλάδα', +]; diff --git a/lang/el/states.php b/lang/el/states.php new file mode 100644 index 0000000..fea5880 --- /dev/null +++ b/lang/el/states.php @@ -0,0 +1,52 @@ + 'Περιφερειακή Ενότητα Αχαΐας', + 'Aetolia-Acarnania Regional Unit' => 'Περιφερειακή Ενότητα Αιτωλοακαρνανίας', + 'Arcadia Prefecture' => 'Νομός Αρκαδίας', + 'Argolis Regional Unit' => 'Περιφερειακή Ενότητα Αργολίδας', + 'Attica Region' => 'Περιφέρεια Αττικής', + 'Boeotia Regional Unit' => 'Περιφερειακή Ενότητα Βοιωτίας', + 'Central Greece Region' => 'Περιφέρεια Στερεάς Ελλάδας', + 'Central Macedonia' => 'Κεντρική Μακεδονία', + 'Chania Regional Unit' => 'Περιφερειακή Ενότητα Χανίων', + 'Corfu Prefecture' => 'Νομός Κέρκυρας', + 'Corinthia Regional Unit' => 'Περιφερειακή Ενότητα Κορινθίας', + 'Crete Region' => 'Περιφέρεια Κρήτης', + 'Drama Regional Unit' => 'Περιφερειακή Ενότητα Δράμας', + 'East Attica Regional Unit' => 'Περιφερειακή Ενότητα Ανατολικής Αττικής', + 'East Macedonia and Thrace' => 'Ανατολική Μακεδονία και Θράκη', + 'Epirus Region' => 'Περιφέρεια Ηπείρου', + 'Euboea' => 'Εύβοια', + 'Grevena Prefecture' => 'Νομός Γρεβενών', + 'Imathia Regional Unit' => 'Περιφερειακή Ενότητα Ημαθίας', + 'Ioannina Regional Unit' => 'Περιφερειακή Ενότητα Ιωαννίνων', + 'Ionian Islands Region' => 'Περιφέρεια Ιονίων Νήσων', + 'Karditsa Regional Unit' => 'Περιφερειακή Ενότητα Καρδίτσας', + 'Kastoria Regional Unit' => 'Περιφερειακή Ενότητα Καστοριάς', + 'Kefalonia Prefecture' => 'Νομός Κεφαλληνίας', + 'Kilkis Regional Unit' => 'Περιφερειακή Ενότητα Κιλκίς', + 'Kozani Prefecture' => 'Νομός Κοζάνης', + 'Laconia' => 'Λακωνία', + 'Larissa Prefecture' => 'Νομός Λάρισας', + 'Lefkada Regional Unit' => 'Περιφερειακή Ενότητα Λευκάδας', + 'Pella Regional Unit' => 'Περιφερειακή Ενότητα Πέλλας', + 'Peloponnese Region' => 'Περιφέρεια Πελοποννήσου', + 'Phthiotis Prefecture' => 'Νομός Φθιώτιδας', + 'Preveza Prefecture' => 'Νομός Πρέβεζας', + 'Serres Prefecture' => 'Νομός Σερρών', + 'South Aegean' => 'Νότιο Αιγαίο', + 'Thessaloniki Regional Unit' => 'Περιφερειακή Ενότητα Θεσσαλονίκης', + 'West Greece Region' => 'Περιφέρεια Δυτικής Ελλάδας', + 'West Macedonia Region' => 'Περιφέρεια Δυτικής Μακεδονίας', +]; diff --git a/src/Command/BackfillMissingSkusCommand.php b/src/Command/BackfillMissingSkusCommand.php new file mode 100644 index 0000000..6bbbbed --- /dev/null +++ b/src/Command/BackfillMissingSkusCommand.php @@ -0,0 +1,60 @@ +option('dry-run'); + + $query = ProductVariant::query()->whereNull('sku'); + $total = $query->count(); + + if ($total === 0) { + $this->info('No variants are missing a SKU.'); + + return; + } + + $this->info(($dryRun ? '[dry-run] ' : '') . "Backfilling SKUs for {$total} variant(s)..."); + + $bar = $this->output->createProgressBar($total); + $bar->start(); + + $query->chunkById(500, function ($variants) use ($dryRun, $bar) { + foreach ($variants as $variant) { + $sku = "SKU-P{$variant->product_id}-V{$variant->id}"; + + if ($dryRun) { + $this->newLine(); + $this->line("Variant {$variant->id}: sku => {$sku}"); + } else { + $variant->update(['sku' => $sku]); + } + + $bar->advance(); + } + }); + + $bar->finish(); + $this->newLine(); + $this->info($dryRun ? 'Dry run complete — no changes were written.' : 'Done.'); + } +} diff --git a/src/Command/InstallLunarCommand.php b/src/Command/InstallLunarCommand.php index 1c0c040..c20f636 100644 --- a/src/Command/InstallLunarCommand.php +++ b/src/Command/InstallLunarCommand.php @@ -66,6 +66,16 @@ class InstallLunarCommand extends Command ]); } + if (! Language::where('code', 'el')->exists()) { + $this->components->info('Adding Greek language'); + + Language::create([ + 'code' => 'el', + 'name' => 'Greek', + 'default' => false, + ]); + } + if (! Currency::whereDefault(true)->exists()) { $this->components->info('Adding a default currency (USD)'); @@ -310,7 +320,10 @@ class InstallLunarCommand extends Command PaymentMethod::create([ 'type' => 'cash-on-delivery', - 'name' => 'Cash on Delivery', + 'name' => [ + 'en' => 'Cash on Delivery', + 'el' => 'Αντικαταβολή', + ], 'driver' => 'cash-on-delivery', 'capture_mode' => 'pay', 'position' => 0, diff --git a/src/CorePlugin.php b/src/CorePlugin.php index 31efae4..33a0b55 100644 --- a/src/CorePlugin.php +++ b/src/CorePlugin.php @@ -28,7 +28,7 @@ use Modules\Core\Catalog\Filament\Extensions\ValuesRelationManagerExtension; use Modules\Core\Localization\Filament\Resources\LanguageLineResource; use Modules\Core\Order\Filament\Extensions\OrderItemsTableExtension; use Modules\Core\Order\Filament\Extensions\OrderPaymentMethodSummaryExtension; -use Modules\Core\Order\Filament\Extensions\OrderRefundActionsExtension; +use Modules\Core\Order\Filament\Extensions\OrderActionsExtension; use Modules\Core\Order\Filament\Extensions\OrderTransactionsExtension; use Modules\Core\Payment\Filament\Resources\PaymentMethodResource; use Modules\Core\Review\Filament\Extensions\ProductResourceExtension; @@ -70,7 +70,7 @@ class CorePlugin implements Plugin ValuesRelationManager::class => ValuesRelationManagerExtension::class, ShippingMethodResource::class => ShippingMethodResourceExtension::class, ListShippingMethod::class => ShippingMethodListExtension::class, - ManageOrder::class => [OrderViewExtension::class, OrderRefundActionsExtension::class, OrderTransactionsExtension::class, OrderPaymentMethodSummaryExtension::class, OrderShipmentsExtension::class], + ManageOrder::class => [OrderViewExtension::class, OrderActionsExtension::class, OrderTransactionsExtension::class, OrderPaymentMethodSummaryExtension::class, OrderShipmentsExtension::class], OrderItemsTable::class => OrderItemsTableExtension::class, ]); diff --git a/src/Order/Filament/Extensions/OrderRefundActionsExtension.php b/src/Order/Filament/Extensions/OrderActionsExtension.php similarity index 74% rename from src/Order/Filament/Extensions/OrderRefundActionsExtension.php rename to src/Order/Filament/Extensions/OrderActionsExtension.php index 39639d2..d90a11c 100644 --- a/src/Order/Filament/Extensions/OrderRefundActionsExtension.php +++ b/src/Order/Filament/Extensions/OrderActionsExtension.php @@ -32,10 +32,6 @@ use ReflectionProperty; * 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 @@ -43,15 +39,28 @@ use ReflectionProperty; * Payment\Support\TransactionDriverAdapter::refundVia() instead of * Lunar\Models\Transaction::refund() — see fixRefundAction()'s own * docblock. + * + * Fix, for capture: same notification fix, but the action() closure is + * also replaced outright — the actual call is routed through + * Payment\Support\TransactionDriverAdapter::capture() instead of + * Lunar\Models\Transaction::capture() (see fixCaptureAction()), so a + * manual backoffice capture goes through the app's own payment driver + * registry and dispatches Payment\Events\PaymentCaptured exactly like a + * checkout-time capture does — the vendor path resolved + * Lunar\Facades\Payments (an entirely separate, unused driver registry) + * and never dispatched that event, which is why Order::status used to + * stay stuck on 'awaiting_payment' after a manual capture even though + * Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus now advances it + * on PaymentCaptured. */ -class OrderRefundActionsExtension extends ViewPageExtension +class OrderActionsExtension 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), + 'capture' => $this->fixCaptureAction($action), default => $action, }, $actions, @@ -123,6 +132,41 @@ class OrderRefundActionsExtension extends ViewPageExtension }); } + /** + * Mirrors fixRefundAction()'s notification fix, but for the "amount" + * field already on the vendor schema — no extra field needed, since + * capture always goes back through the transaction's own original + * driver (there's no equivalent to refunding via a different driver). + */ + private function fixCaptureAction(Action $action): Action + { + return $action->action(function (array $data, Action $action) { + $transaction = Transaction::find($data['transaction']); + + if (! $transaction instanceof CoreTransaction) { + $action->failureNotification(fn () => Notification::make('capture_failure')->danger()->title('Transaction not found.')) + ->sendFailureNotification(); + + throw new Halt; + } + + $response = app(TransactionDriverAdapter::class)->capture( + $transaction, + (int) bcmul((string) $data['amount'], (string) $transaction->order->currency->factor), + ); + + if (! $response->success) { + $action->failureNotification( + fn () => Notification::make('capture_failure')->color('danger')->title($response->message) + )->sendFailureNotification(); + + throw new Halt; + } + + $action->success(); + }); + } + /** * @return array */ @@ -163,37 +207,4 @@ class OrderRefundActionsExtension extends ViewPageExtension 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; - } - }); - } } diff --git a/src/Order/Filament/Extensions/OrderItemsTableExtension.php b/src/Order/Filament/Extensions/OrderItemsTableExtension.php index 8fda303..7a8bcc8 100644 --- a/src/Order/Filament/Extensions/OrderItemsTableExtension.php +++ b/src/Order/Filament/Extensions/OrderItemsTableExtension.php @@ -8,7 +8,7 @@ use Filament\Tables\Table; use Lunar\Admin\Support\Extending\BaseExtension; /** - * Same fix as OrderRefundActionsExtension, applied to the order lines + * Same fix as OrderActionsExtension, 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 diff --git a/src/Order/Filament/Extensions/OrderPaymentMethodSummaryExtension.php b/src/Order/Filament/Extensions/OrderPaymentMethodSummaryExtension.php index 426697f..29bb4f2 100644 --- a/src/Order/Filament/Extensions/OrderPaymentMethodSummaryExtension.php +++ b/src/Order/Filament/Extensions/OrderPaymentMethodSummaryExtension.php @@ -42,6 +42,8 @@ class OrderPaymentMethodSummaryExtension extends ViewPageExtension return null; } - return PaymentMethod::where('type', $type)->value('name') ?? $type; + $method = PaymentMethod::where('type', $type)->first(); + + return $method?->translate('name') ?? $type; } } diff --git a/src/Order/Listeners/ApplyResolvedPaymentStatus.php b/src/Order/Listeners/ApplyResolvedPaymentStatus.php index 2df32a2..28d6524 100644 --- a/src/Order/Listeners/ApplyResolvedPaymentStatus.php +++ b/src/Order/Listeners/ApplyResolvedPaymentStatus.php @@ -6,6 +6,7 @@ use Illuminate\Support\Facades\Event; use Lunar\Models\Order; use Modules\Core\Checkout\Events\OrderPlaced; use Modules\Core\Order\Enums\PaymentStatus; +use Modules\Core\Order\Services\OrderStatusFlow; use Modules\Core\Order\Services\OrderStatusWriter; use Modules\Core\Order\Support\OrderStatus; use Modules\Core\Payment\Events\PaymentAuthorized; @@ -16,13 +17,16 @@ use Modules\Core\Payment\Events\PaymentRefunded; * Registered against PaymentCaptured, PaymentAuthorized, AND * PaymentRefunded (see OrderServiceProvider). * - * A capture/authorization only ever writes Order::paid/paid_at (via - * OrderStatusWriter::markPaid()) — never `status`. Confirmed with the - * user: status leaving 'awaiting_payment' is always a staff-driven - * "Update Status" click, regardless of payment method — no special-casing - * prepaid vs. cash-on-delivery. A prepaid order briefly sitting at - * 'awaiting_payment' with paid = true (until staff notice and advance it) - * is expected, not a bug. + * PaymentCaptured writes both Order::paid/paid_at (via + * OrderStatusWriter::markPaid()) AND advances `status` out of + * 'awaiting_payment' to the next step in the order's flow (see + * OrderStatusFlow::nextOptions()) — re-confirmed with the user: a + * captured payment, manual or via Stripe's webhook, should never leave an + * order sitting at 'awaiting_payment'. Only fires when status is still + * exactly 'awaiting_payment', so a duplicate/delayed capture event never + * regresses an order staff already advanced further. PaymentAuthorized + * only marks paid — an authorization is not yet captured funds, so + * status stays put until the actual capture. * * A refund still moves `status` (returned -> refunded/partially_refunded) * — refunds are a normal step in Modules\Core\Order\Services\ @@ -44,6 +48,7 @@ class ApplyResolvedPaymentStatus { public function __construct( private readonly OrderStatusWriter $writer, + private readonly OrderStatusFlow $flow, ) {} public function handle(PaymentCaptured|PaymentAuthorized|PaymentRefunded $event): void @@ -66,12 +71,30 @@ class ApplyResolvedPaymentStatus $this->writer->markPaid($order, $event::class); + if ($event instanceof PaymentCaptured) { + $this->advancePastAwaitingPayment($order, $event); + } + if (! $wasPlaced) { $order->update(['placed_at' => $order->placed_at ?? now()]); Event::dispatch(new OrderPlaced($order)); } } + private function advancePastAwaitingPayment(Order $order, PaymentCaptured $event): void + { + if ($order->status !== 'awaiting_payment') { + return; + } + + $next = $this->flow->nextOptions($order); + $target = array_key_first($next); + + if ($target !== null) { + $this->writer->write($order, $target, $event::class); + } + } + /** * Requires the refund Transaction row to already exist (Modules\Core\ * Order\Listeners\RecordPaymentTransaction must run first — see diff --git a/src/Order/Services/TransactionRecorder.php b/src/Order/Services/TransactionRecorder.php index fc13e29..423959a 100644 --- a/src/Order/Services/TransactionRecorder.php +++ b/src/Order/Services/TransactionRecorder.php @@ -49,6 +49,8 @@ class TransactionRecorder 'reference' => $result->reference, 'status' => $result->status->name, 'notes' => $result->failureReason, + 'card_type' => $result->meta['card_type'] ?? null, + 'last_four' => $result->meta['last_four'] ?? null, 'meta' => $result->meta, ]); } diff --git a/src/Payment/Drivers/BankTransferPaymentDriver.php b/src/Payment/Drivers/BankTransferPaymentDriver.php index 357dd5a..da8ab1f 100644 --- a/src/Payment/Drivers/BankTransferPaymentDriver.php +++ b/src/Payment/Drivers/BankTransferPaymentDriver.php @@ -22,7 +22,7 @@ use Modules\Core\Payment\Events\PaymentRefunded; * 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 + * Order\Filament\Extensions\OrderActionsExtension). 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 diff --git a/src/Payment/Drivers/StripePaymentDriver.php b/src/Payment/Drivers/StripePaymentDriver.php index 9a8a8f8..b304a37 100644 --- a/src/Payment/Drivers/StripePaymentDriver.php +++ b/src/Payment/Drivers/StripePaymentDriver.php @@ -4,9 +4,6 @@ namespace Modules\Core\Payment\Drivers; use Lunar\DataTypes\Price; use Lunar\Models\Currency; -use Lunar\Stripe\Facades\Stripe; -use Lunar\Stripe\Managers\StripeManager; -use Lunar\Stripe\Models\StripePaymentIntent; use Modules\Core\Payment\Contracts\Configurable; use Modules\Core\Payment\Contracts\HandlesPaymentCallback; use Modules\Core\Payment\Contracts\SupportsAuthorization; @@ -26,17 +23,20 @@ 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 Modules\Core\Payment\Models\StripePaymentIntent; +use Modules\Core\Payment\Support\StripeManager; use Stripe\Exception\ApiErrorException; use Stripe\PaymentIntent; /** * Talks to Stripe's PaymentIntent API directly — deliberately NOT via - * Lunar\Stripe\Facades\Stripe::createIntent()/fetchOrCreateIntent(), which - * take a Lunar\Models\Cart and derive amount/currency from it. Payment - * 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. + * Lunar's own checkout flow (lunarphp/stripe, since removed — see + * Modules\Core\Payment\Support\StripeManager's own docblock), which took a + * Lunar\Models\Cart and derived amount/currency from it. Payment 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. * * Every amount that crosses this class's own boundary is converted right * there: Lunar's Price -> Stripe's minor-unit int going INTO a gateway @@ -45,12 +45,11 @@ use Stripe\PaymentIntent; * Nothing outside this class ever sees a Stripe-scaled integer. * * 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. + * back to whatever $context identified this attempt is solved via real + * cart_id/order_id columns on Modules\Core\Payment\Models\ + * StripePaymentIntent (a table this app now owns outright, already shaped + * for exactly this), not a generic context blob. See docs/payments.md + * "Async resolution" for the full reasoning. */ class StripePaymentDriver implements Configurable, @@ -61,16 +60,18 @@ class StripePaymentDriver implements SupportsRefunds, HandlesPaymentCallback { + public function __construct( + private readonly StripeManager $stripe, + ) {} + /** - * Same key lunarphp/stripe's own StripeManager reads its API key from - * (Stripe::setApiKey(config('services.stripe.key'))) — no key, no - * usable driver. + * Same key StripeManager reads its API key from — no key, no usable + * driver. */ public function isConfigured(): bool { return filled(config('services.stripe.key')); } - /** * Atomic charge — capture_method: automatic. Stripe still frequently * confirms into requires_action/requires_confirmation rather than @@ -100,16 +101,22 @@ class StripePaymentDriver implements 'currency' => $amount->currency->code, 'capture_method' => $captureMethod, 'confirm' => true, + // 'never' rather than the client-side paymentMethodTypes: ['card'] + // restriction alone — the storefront's Payment Element already + // excludes every redirect-based method, but without this Stripe + // still falls back to whatever's enabled in the Dashboard and + // demands a return_url on confirm. Setting this unconditionally + // (not only when no payment_method is given) matches the actual + // flow: a payment_method is always supplied here. + 'automatic_payment_methods' => ['enabled' => true, 'allow_redirects' => 'never'], ]; if (isset($data['payment_method'])) { $params['payment_method'] = $data['payment_method']; - } else { - $params['automatic_payment_methods'] = ['enabled' => true]; } try { - $paymentIntent = Stripe::getClient()->paymentIntents->create($params); + $paymentIntent = $this->stripe->getClient()->paymentIntents->create($params); } catch (ApiErrorException $e) { return $this->declined($type, $amount, $e, $context, authorizing: $captureMethod === 'manual'); } @@ -123,7 +130,7 @@ class StripePaymentDriver implements { [$intentModel, $type, $context] = $this->resolveIntentModel($reference, $context, $data['type'] ?? ''); - $paymentIntent = Stripe::getClient()->paymentIntents->retrieve($reference); + $paymentIntent = $this->stripe->getClient()->paymentIntents->retrieve($reference); $authorizing = $paymentIntent->capture_method === PaymentIntent::CAPTURE_METHOD_MANUAL; @@ -131,7 +138,7 @@ class StripePaymentDriver implements // 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); + $paymentIntent = $this->stripe->getClient()->paymentIntents->capture($reference); } $intentModel?->update(['status' => $paymentIntent->status]); @@ -146,7 +153,7 @@ class StripePaymentDriver implements [$intentModel, $type, $context] = $this->resolveIntentModel($reference, $context); try { - $paymentIntent = Stripe::getClient()->paymentIntents->capture($reference, [ + $paymentIntent = $this->stripe->getClient()->paymentIntents->capture($reference, [ 'amount_to_capture' => StripeManager::toStripeAmount($amount->value, $amount->currency), ]); } catch (ApiErrorException $e) { @@ -165,6 +172,7 @@ class StripePaymentDriver implements reference: $paymentIntent->id, amount: $amount, raw: $paymentIntent->toArray(), + meta: $this->cardMetaFromIntent($paymentIntent), ); $paymentIntent->status === PaymentIntent::STATUS_SUCCEEDED @@ -179,7 +187,7 @@ class StripePaymentDriver implements [$intentModel, $type, $context] = $this->resolveIntentModel($reference, $context); try { - $paymentIntent = Stripe::getClient()->paymentIntents->cancel($reference); + $paymentIntent = $this->stripe->getClient()->paymentIntents->cancel($reference); } catch (ApiErrorException $e) { $result = $this->failure($amount, $e, $reference); PaymentVoidFailed::dispatch($type, $result, $context); @@ -210,7 +218,7 @@ class StripePaymentDriver implements [$intentModel, $type, $context] = $this->resolveIntentModel($reference, $context); try { - $refund = Stripe::getClient()->refunds->create([ + $refund = $this->stripe->getClient()->refunds->create([ 'payment_intent' => $reference, 'amount' => StripeManager::toStripeAmount($amount->value, $amount->currency), ]); @@ -247,7 +255,7 @@ class StripePaymentDriver implements 'order_id' => $context['order_id'] ?? null, 'status' => $paymentIntent->status, 'payment_type' => $type, - 'context' => json_encode($context), + 'context' => $context, ]); } @@ -272,28 +280,10 @@ class StripePaymentDriver implements return [ $intentModel, $intentModel?->payment_type ?? $typeFallback, - $this->decodeContext($intentModel) ?? $context, + $intentModel?->context ?? $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|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 @@ -335,6 +325,7 @@ class StripePaymentDriver implements amount: $amount, failureReason: $paymentIntent->last_payment_error->message ?? null, raw: $paymentIntent->toArray(), + meta: $status === PaymentResultStatus::Pending ? [] : $this->cardMetaFromIntent($paymentIntent), continuation: $continuation, ); @@ -357,6 +348,40 @@ class StripePaymentDriver implements return $result; } + /** + * card_type/last_four for Modules\Core\Order\Services\ + * TransactionRecorder to map onto Transaction (see PaymentResult:: + * $meta's own docblock) — same fields, same source + * (payment_method_details on the underlying Charge) as lunarphp/ + * stripe's own StoreCharges, just reached via latest_charge instead of + * an order-level charge list, since this driver has no Order/Cart to + * enumerate charges from. + * + * @return array{card_type?: string, last_four?: string} + */ + private function cardMetaFromIntent(PaymentIntent $paymentIntent): array + { + $chargeId = $paymentIntent->latest_charge; + + if (blank($chargeId)) { + return []; + } + + $charge = $this->stripe->getCharge(is_string($chargeId) ? $chargeId : $chargeId->id); + + $paymentType = collect($charge->payment_method_details)->keys()->first(); + $details = collect($charge->payment_method_details)->first(); + + if (blank($details)) { + return []; + } + + return array_filter([ + 'card_type' => $details['brand'] ?? $paymentType, + 'last_four' => $details['last4'] ?? null, + ], fn ($value) => filled($value)); + } + private function declined(string $type, Price $amount, ApiErrorException $e, array $context, bool $authorizing): PaymentResult { $result = $this->failure($amount, $e); diff --git a/src/Payment/Filament/Resources/PaymentMethodResource.php b/src/Payment/Filament/Resources/PaymentMethodResource.php index 363d24f..5a441d6 100644 --- a/src/Payment/Filament/Resources/PaymentMethodResource.php +++ b/src/Payment/Filament/Resources/PaymentMethodResource.php @@ -12,6 +12,7 @@ use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\ToggleColumn; use Filament\Tables\Table; use Illuminate\Support\Facades\Event; +use Lunar\Admin\Support\Forms\Components\TranslatedText; use Modules\Core\Payment\Contracts\Configurable; use Modules\Core\Payment\Events\PaymentMethodsReordered; use Modules\Core\Payment\Filament\Resources\PaymentMethodResource\Pages\ListPaymentMethods; @@ -76,7 +77,7 @@ class PaymentMethodResource extends Resource ->sortable(), TextColumn::make('name') ->label('Name') - ->searchable(), + ->state(fn (PaymentMethod $record) => $record->translate('name')), TextColumn::make('type') ->label('Type'), TextColumn::make('driver') @@ -124,10 +125,9 @@ class PaymentMethodResource extends Resource public static function getFormComponents(): array { return [ - TextInput::make('name') + TranslatedText::make('name') ->label('Name') - ->required() - ->maxLength(255), + ->required(), 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.') diff --git a/src/Payment/Http/Controllers/StripeWebhookController.php b/src/Payment/Http/Controllers/StripeWebhookController.php index f60c534..67fc7f4 100644 --- a/src/Payment/Http/Controllers/StripeWebhookController.php +++ b/src/Payment/Http/Controllers/StripeWebhookController.php @@ -9,18 +9,17 @@ 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. + * A boboko-owned webhook endpoint for Stripe — never went through Lunar's + * own Payments::driver('stripe') flow (the flow StripePaymentDriver was + * built to replace, see that class's own docblock), and lunarphp/stripe + * has since been removed entirely (see Modules\Core\Payment\Support\ + * StripeManager's own docblock). Signature verification is handled by + * Modules\Core\Payment\Http\Middleware\StripeWebhookMiddleware, registered + * on this route (see src/Payment/routes/webhooks.php) — pure Stripe SDK + * verification + event-type filtering. 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 diff --git a/src/Payment/Http/Middleware/StripeWebhookMiddleware.php b/src/Payment/Http/Middleware/StripeWebhookMiddleware.php new file mode 100644 index 0000000..6a1fd4c --- /dev/null +++ b/src/Payment/Http/Middleware/StripeWebhookMiddleware.php @@ -0,0 +1,51 @@ +header('Stripe-Signature'); + + try { + $event = Webhook::constructEvent( + $request->getContent(), + $stripeSig, + $secret + ); + } catch (UnexpectedValueException|SignatureVerificationException $e) { + abort(400, $e->getMessage()); + } + + if (! in_array( + $event->type, + [ + 'payment_intent.payment_failed', + 'payment_intent.succeeded', + ] + )) { + return response('', 200); + } + + return $next($request); + } +} diff --git a/src/Payment/Models/PaymentMethod.php b/src/Payment/Models/PaymentMethod.php index 39a6016..6ff63ea 100644 --- a/src/Payment/Models/PaymentMethod.php +++ b/src/Payment/Models/PaymentMethod.php @@ -4,6 +4,7 @@ namespace Modules\Core\Payment\Models; use Illuminate\Database\Eloquent\Casts\AsArrayObject; use Illuminate\Database\Eloquent\Model; +use Lunar\Base\Traits\HasTranslations; /** * A merchant-configured payment method — the DB-instance layer, admin @@ -11,7 +12,16 @@ use Illuminate\Database\Eloquent\Model; * shipping_methods table already has (see docs/payments.md): * - type: unique, machine-facing slug (Cart::meta['payment_method'], * ApplyPaymentMethodFee's lookup key, every Payment event's $type). - * - name: admin-facing label. + * - name: admin-facing label, locale-keyed JSON (e.g. + * {"en": "Cash On Delivery", "el": "Αντικαταβολή"}) — same shape/ + * resolution as Product/Collection names (Lunar\Base\Traits\ + * HasTranslations), just applied directly to this column rather than + * through attribute_data, since this is a merchant settings row, not + * a catalog attribute. Rendered in Filament via Lunar's own + * Lunar\Admin\Support\Forms\Components\TranslatedText — one input per + * configured Language row, no bespoke translation UI. Resolve a + * display string with $method->translate('name') (locale defaults to + * app()->getLocale(), falling back to the store's default language). * - driver: the Modules\Core\Payment\Services\PaymentDriverRegistry key * — NOT the same as `type`, and not unique (two rows can share one * driver, e.g. two differently-named offline-style methods). @@ -28,12 +38,15 @@ use Illuminate\Database\Eloquent\Model; */ class PaymentMethod extends Model { + use HasTranslations; + protected $guarded = []; protected $casts = [ 'enabled' => 'boolean', 'position' => 'integer', 'driver_missing_at' => 'datetime', + 'name' => 'array', 'data' => AsArrayObject::class, ]; } diff --git a/src/Payment/Models/StripePaymentIntent.php b/src/Payment/Models/StripePaymentIntent.php new file mode 100644 index 0000000..afefe12 --- /dev/null +++ b/src/Payment/Models/StripePaymentIntent.php @@ -0,0 +1,32 @@ + 'array', + ]; +} diff --git a/src/Payment/Support/StripeManager.php b/src/Payment/Support/StripeManager.php new file mode 100644 index 0000000..dc2f82e --- /dev/null +++ b/src/Payment/Support/StripeManager.php @@ -0,0 +1,126 @@ + config('services.stripe.key'), + ]); + } + + public function getCharge(string $chargeId): Charge + { + return $this->getClient()->charges->retrieve($chargeId); + } + + /** + * Zero-decimal currencies, per Stripe. The amount sent to Stripe is the + * major unit amount as-is. + * + * @see https://docs.stripe.com/currencies#zero-decimal + */ + protected const ZERO_DECIMAL_CURRENCIES = [ + 'bif', 'clp', 'djf', 'gnf', 'jpy', 'kmf', 'krw', 'mga', 'pyg', + 'rwf', 'ugx', 'vnd', 'vuv', 'xaf', 'xof', 'xpf', + ]; + + /** + * Three-decimal currencies, per Stripe. The amount sent to Stripe is the + * major unit amount multiplied by 1000. + * + * @see https://docs.stripe.com/currencies#three-decimal + */ + protected const THREE_DECIMAL_CURRENCIES = ['bhd', 'jod', 'kwd', 'omr', 'tnd']; + + /** + * HUF, TWD and UGX are ISO zero-decimal currencies, but Stripe still + * requires amounts to be sent as if they had two decimal places. + * + * @see https://docs.stripe.com/currencies#special-cases + */ + protected const SPECIAL_ZERO_DECIMAL_CURRENCIES = ['huf', 'twd', 'ugx']; + + /** + * Convert a Lunar price value to the amount expected by Stripe. + * + * Lunar stores prices as integers scaled by `Currency::decimal_places`, + * which merchants can set independently of what Stripe expects for a + * given currency. This converts back to the major unit amount first, + * then re-scales it to whatever sub-unit Stripe requires for the + * currency, so the result is correct regardless of how the merchant has + * configured `Currency::decimal_places`. + * + * @see https://docs.stripe.com/currencies + */ + public static function toStripeAmount(int $value, CurrencyContract $currency): int + { + return self::rescale($value, max($currency->decimal_places, 0), self::stripeDecimalPlaces($currency)); + } + + /** + * Convert an amount received from Stripe back to a Lunar price value, + * scaled by `Currency::decimal_places`. Inverse of `toStripeAmount()`. + */ + public static function fromStripeAmount(int $amount, CurrencyContract $currency): int + { + return self::rescale($amount, self::stripeDecimalPlaces($currency), max($currency->decimal_places, 0)); + } + + /** + * The number of decimal places Stripe expects amounts in for a currency. + */ + protected static function stripeDecimalPlaces(CurrencyContract $currency): int + { + $code = strtolower($currency->code); + + // UGX is also in the zero-decimal list; the special case takes precedence. + if (in_array($code, self::SPECIAL_ZERO_DECIMAL_CURRENCIES, true)) { + return 2; + } + + if (in_array($code, self::ZERO_DECIMAL_CURRENCIES, true)) { + return 0; + } + + if (in_array($code, self::THREE_DECIMAL_CURRENCIES, true)) { + return 3; + } + + return 2; + } + + protected static function rescale(int $value, int $fromDecimalPlaces, int $toDecimalPlaces): int + { + $exponent = $toDecimalPlaces - $fromDecimalPlaces; + + if ($exponent >= 0) { + return $value * (10 ** $exponent); + } + + $divisor = 10 ** (-$exponent); + + return intdiv(abs($value) + intdiv($divisor, 2), $divisor) * ($value < 0 ? -1 : 1); + } +} diff --git a/src/Payment/Support/TransactionDriverAdapter.php b/src/Payment/Support/TransactionDriverAdapter.php index 24c8c02..4496a55 100644 --- a/src/Payment/Support/TransactionDriverAdapter.php +++ b/src/Payment/Support/TransactionDriverAdapter.php @@ -54,7 +54,7 @@ class TransactionDriverAdapter /** * The PaymentDriverRegistry key $transaction was originally taken * through — what refund()/capture() resolve against by default, and - * what Order\Filament\Extensions\OrderRefundActionsExtension defaults + * what Order\Filament\Extensions\OrderActionsExtension defaults * its "Refund via" driver Select to, before an admin overrides it. */ public function driverKeyFor(Transaction $transaction): ?string @@ -72,7 +72,7 @@ class TransactionDriverAdapter * when refunding through the transaction's own original driver. * * Called directly by Order\Filament\Extensions\ - * OrderRefundActionsExtension when the admin picks a different driver + * OrderActionsExtension when the admin picks a different driver * in the refund modal, bypassing Lunar\Models\Transaction::refund() * (whose fixed refund(int $amount, $notes = null) signature has no * room for a driver override) — see that extension's own docblock. diff --git a/src/Payment/routes/webhooks.php b/src/Payment/routes/webhooks.php index 96899be..2036925 100644 --- a/src/Payment/routes/webhooks.php +++ b/src/Payment/routes/webhooks.php @@ -2,8 +2,8 @@ use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken; use Illuminate\Support\Facades\Route; -use Lunar\Stripe\Http\Middleware\StripeWebhookMiddleware; use Modules\Core\Payment\Http\Controllers\StripeWebhookController; +use Modules\Core\Payment\Http\Middleware\StripeWebhookMiddleware; Route::post( config('payment.stripe.webhook_path', 'payments/stripe/webhook'), diff --git a/src/Providers/CoreServiceProvider.php b/src/Providers/CoreServiceProvider.php index 2756ca1..bd3f085 100644 --- a/src/Providers/CoreServiceProvider.php +++ b/src/Providers/CoreServiceProvider.php @@ -5,6 +5,7 @@ namespace Modules\Core\Providers; use Illuminate\Support\Facades\Blade; use Illuminate\Support\ServiceProvider; use Modules\Core\Command\AnonymizeCommand; +use Modules\Core\Command\BackfillMissingSkusCommand; use Modules\Core\Command\ExportCleanupCommand; use Modules\Core\Command\ExportCommand; use Modules\Core\Command\ImportCommand; @@ -24,6 +25,7 @@ class CoreServiceProvider extends ServiceProvider $this->loadViewsFrom(__DIR__ . '/../../resources/views', 'core'); Blade::anonymousComponentPath(__DIR__ . '/../../resources/views', 'core'); $this->loadMigrationsFrom(__DIR__ . '/../../database/migrations'); + $this->loadTranslationsFrom(__DIR__ . '/../../lang', 'core'); $this->publishes([ __DIR__ . '/../../config/core.php' => config_path('core.php'), @@ -36,7 +38,7 @@ class CoreServiceProvider extends ServiceProvider ], 'core-assets'); if ($this->app->runningInConsole()) { - $this->commands([AnonymizeCommand::class, ExportCommand::class, ExportCleanupCommand::class, ImportCommand::class, MigrateImportCommand::class, TuneProductSearchCommand::class]); + $this->commands([AnonymizeCommand::class, ExportCommand::class, ExportCleanupCommand::class, ImportCommand::class, MigrateImportCommand::class, TuneProductSearchCommand::class, BackfillMissingSkusCommand::class]); //Overriding lunar:install $this->app->booted(fn () => $this->commands([InstallLunarCommand::class])); diff --git a/src/Providers/ShippingServiceProvider.php b/src/Providers/ShippingServiceProvider.php index 7e23709..55e8121 100644 --- a/src/Providers/ShippingServiceProvider.php +++ b/src/Providers/ShippingServiceProvider.php @@ -27,6 +27,7 @@ use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface; use Modules\Core\Shipping\Filament\Pages\ManageShippingRates; use Modules\Core\Shipping\Jobs\PollShipmentTrackingJob; use Modules\Core\Shipping\Listeners\InvalidateShippingOptions; +use Modules\Core\Shipping\Support\FulfillmentType; use Modules\Core\Shipping\Models\Shipment; class ShippingServiceProvider extends ServiceProvider @@ -80,8 +81,10 @@ class ShippingServiceProvider extends ServiceProvider // resolveCarrier() for the same lookup pattern already used to // resolve a carrier driver from it). // - // Reads ShippingMethod.data['fulfillment_type'] directly rather - // than through a ShippingMethod::macro('isStorePickup', ...) — + // Resolves via Modules\Core\Shipping\Support\FulfillmentType (driver- + // declared for acs/box-now, merchant-configured data['fulfillment_type'] + // fallback for table-rate-shipping's generic drivers) rather than + // through a ShippingMethod::macro('isStorePickup', ...) — // Lunar\Base\Traits\HasModelExtending::__callStatic() (used by // Lunar\Shipping\Models\ShippingMethod via Lunar\Base\BaseModel) // intercepts EVERY unmatched static call, including macro() @@ -91,8 +94,7 @@ class ShippingServiceProvider extends ServiceProvider // false. (Lunar\Models\Order is unaffected because it declares // its own macro() method directly, bypassing __callStatic // entirely — that's why Order::macro('isStorePickupOrder', ...) - // below still works.) Defaults to 'carrier' (false) for any row - // saved before this field existed. + // below still works.) Order::macro('isStorePickupOrder', function () { /** @var Order $this */ $code = $this->shippingAddress?->shipping_option; @@ -108,7 +110,7 @@ class ShippingServiceProvider extends ServiceProvider // attribute avoids that entirely. $method = ShippingMethod::where('code', $code)->first(); - return ($method?->data['fulfillment_type'] ?? 'carrier') === 'store_pickup'; + return $method && FulfillmentType::isStorePickup($method); }); foreach ([CartLineAdded::class, CartLineUpdated::class, CartLineRemoved::class, CartCleared::class, ShippingAddressSet::class] as $event) { diff --git a/src/Shipping/Carriers/Acs/AcsRateDriver.php b/src/Shipping/Carriers/Acs/AcsRateDriver.php index 74ce760..5a780a6 100644 --- a/src/Shipping/Carriers/Acs/AcsRateDriver.php +++ b/src/Shipping/Carriers/Acs/AcsRateDriver.php @@ -10,10 +10,12 @@ use Lunar\Shipping\Models\ShippingRate; use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException; use Modules\Core\Shipping\Concerns\CachesLivePricing; use Modules\Core\Shipping\Concerns\ResolvesFixedPricing; +use Modules\Core\Shipping\Contracts\DeclaresFulfillmentType; use Modules\Core\Shipping\Contracts\SupportsLivePricing; +use Modules\Core\Shipping\Support\ShippingMethodName; use Modules\Core\Shipping\Support\WeightCalculator; -class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing +class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing, DeclaresFulfillmentType { use ResolvesFixedPricing; use CachesLivePricing; @@ -30,6 +32,11 @@ class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing return 'ACS Courier'; } + public function fulfillmentType(): string + { + return 'carrier'; + } + public function description(): string { return 'Live rate quote from ACS Courier.'; @@ -84,7 +91,7 @@ class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing $amount = (int) round(($response->valueOutput['Total_Ammount'] ?? 0) * 100); return new ShippingOption( - name: $shippingMethod->name ?: $this->name(), + name: ShippingMethodName::resolve($shippingMethod) ?: $this->name(), description: $shippingMethod->description ?: $this->description(), identifier: $shippingRate->getIdentifier(), price: new Price($amount, $cart->currency, 1), diff --git a/src/Shipping/Carriers/BoxNow/BoxNowRateDriver.php b/src/Shipping/Carriers/BoxNow/BoxNowRateDriver.php index 46fe211..cd9c392 100644 --- a/src/Shipping/Carriers/BoxNow/BoxNowRateDriver.php +++ b/src/Shipping/Carriers/BoxNow/BoxNowRateDriver.php @@ -7,6 +7,7 @@ use Lunar\Shipping\DataTransferObjects\ShippingOptionRequest; use Lunar\Shipping\Interfaces\ShippingRateInterface; use Lunar\Shipping\Models\ShippingRate; use Modules\Core\Shipping\Concerns\ResolvesFixedPricing; +use Modules\Core\Shipping\Contracts\DeclaresFulfillmentType; /** * Box Now has no pricing API, so this always resolves the method's normal @@ -14,7 +15,7 @@ use Modules\Core\Shipping\Concerns\ResolvesFixedPricing; * flat-rate/ship-by drivers use. Does not implement SupportsLivePricing: * there is no live option to offer. */ -class BoxNowRateDriver implements ShippingRateInterface +class BoxNowRateDriver implements ShippingRateInterface, DeclaresFulfillmentType { use ResolvesFixedPricing; @@ -25,6 +26,11 @@ class BoxNowRateDriver implements ShippingRateInterface return 'Box Now Locker Delivery'; } + public function fulfillmentType(): string + { + return 'carrier'; + } + public function description(): string { return 'Deliver to a Box Now parcel locker.'; diff --git a/src/Shipping/Concerns/ResolvesFixedPricing.php b/src/Shipping/Concerns/ResolvesFixedPricing.php index d0a31bd..ea2ce52 100644 --- a/src/Shipping/Concerns/ResolvesFixedPricing.php +++ b/src/Shipping/Concerns/ResolvesFixedPricing.php @@ -6,6 +6,8 @@ use Lunar\DataTypes\ShippingOption; use Lunar\Facades\Pricing; use Lunar\Shipping\Models\ShippingMethod; use Lunar\Shipping\Models\ShippingRate; +use Modules\Core\Shipping\Support\FulfillmentType; +use Modules\Core\Shipping\Support\ShippingMethodName; /** * Shared by any carrier driver that also supports Lunar's own price-break @@ -31,12 +33,13 @@ trait ResolvesFixedPricing } return new ShippingOption( - name: $shippingMethod->name ?: $this->name(), + name: ShippingMethodName::resolve($shippingMethod) ?: $this->name(), description: $shippingMethod->description ?: $this->description(), identifier: $shippingRate->getIdentifier(), price: $pricing->matched->price, taxClass: $shippingRate->getTaxClass(), taxReference: $shippingRate->getTaxReference(), + collect: FulfillmentType::isStorePickup($shippingMethod), ); } } diff --git a/src/Shipping/Contracts/DeclaresFulfillmentType.php b/src/Shipping/Contracts/DeclaresFulfillmentType.php new file mode 100644 index 0000000..7eced62 --- /dev/null +++ b/src/Shipping/Contracts/DeclaresFulfillmentType.php @@ -0,0 +1,25 @@ +components([ - ...$this->replaceChargeByField( - $this->replaceDriverField($schema->getComponents()) - ), - $this->fulfillmentTypeSelect(), - ]); + return $schema->components( + $this->replaceFulfillmentTypeField( + $this->replaceChargeByField( + $this->replaceNameField( + $this->replaceDriverField($schema->getComponents()) + ) + ) + ) + ); } /** - * ShippingMethod.data['fulfillment_type'] — 'carrier' (default) or - * 'store_pickup'. Same free-form-`data`-column pattern as charge_by - * above, not a migrated column: ShippingMethod is a vendor - * (lunarphp/table-rate-shipping) table, and this codebase avoids - * forking vendor migrations for a merchant-configurable extra (see - * PaymentMethod.data.fee for the same convention on a different - * vendor-adjacent model). - * - * What this actually gates: Modules\Core\Shipping\Extensions\ - * OrderViewExtension's "Create Shipment" action only makes sense for - * a 'carrier' method (it books a real carrier voucher) — a - * 'store_pickup' order instead moves through Order.status - * 'ready-for-pickup' -> a staff "Mark Picked Up" action, no shipment - * ever created. See docs/checkout.md for the full status-flow design. + * Replaces the vendor's plain-string `name` TextInput with + * Lunar's own TranslatedText — `name` is now a locale-keyed JSON + * column (see database/migrations/..._make_shipping_methods_name_translatable.php), + * same shape/resolution as PaymentMethod.name and Product/Collection + * names (Lunar\Base\Traits\HasTranslations). */ + private function replaceNameField(array $components): array + { + return array_map(function (Component $component) { + if (method_exists($component, 'getName') && $component->getName() === 'name') { + return $this->translatedNameField(); + } + + if (in_array(HasChildComponents::class, class_uses_recursive($component), true)) { + $component->schema($this->replaceNameField($component->getChildComponents())); + } + + return $component; + }, $components); + } + + /** + * ShippingMethod.name is a locale-keyed JSON column (see database/ + * migrations/..._make_shipping_methods_name_translatable.php), but + * ShippingMethod is a vendor Eloquent model with no cast declared for + * it — Lunar\Shipping\Models\ShippingMethod only casts `data`, and + * there's no ModelManifest contract wired up to swap in a first-party + * subclass that adds one (Contracts\ShippingMethod exists but is + * never bound — see this class's own git history/nameColumn() for + * the same gap on the read side). TranslatedText itself round-trips + * plain array state, so afterStateHydrated()/dehydrateStateUsing() + * decode/encode the raw JSON string at the field boundary instead — + * the model attribute is a string on the way in and out, only ever + * an array while Filament's schema state holds it. + */ + private function translatedNameField(): TranslatedText + { + $field = TranslatedText::make('name') + ->label('Name') + ->required() + ->afterStateHydrated(function (TranslatedText $component, $state) { + $decoded = json_decode((string) $state, true); + $component->state(is_array($decoded) ? $decoded : []); + }) + ->dehydrateStateUsing(fn ($state) => json_encode(is_array($state) ? $state : [])); + + $field->expanded = true; + + return $field; + } + + /** + * Inserts the `fulfillment_type` Select right after `charge_by`, in + * the SAME Group (vendor's own `Group::make([getChargeByFormComponent()]) + * ->columns(2)`) — formerly appended at the very end of the whole + * form, disconnected from `driver`/`charge_by`, the decisions it + * actually relates to. Only rendered at all for a driver that DOESN'T + * already declare its own fulfillment type (see Modules\Core\Shipping\ + * Contracts\DeclaresFulfillmentType, Modules\Core\Shipping\Support\ + * FulfillmentType) — acs/box-now are unambiguously carrier-only, so + * asking a merchant to also pick "Carrier delivery" for every ACS/Box + * Now method was redundant, error-prone config with no real decision + * behind it. Still offered for table-rate-shipping's generic drivers + * (flat-rate, ship-by, free-shipping), which are genuinely ambiguous. + */ + private function replaceFulfillmentTypeField(array $components): array + { + $result = []; + + foreach ($components as $component) { + $result[] = $component; + + if (method_exists($component, 'getName') && $component->getName() === 'charge_by') { + $result[] = $this->fulfillmentTypeSelect(); + } elseif (in_array(HasChildComponents::class, class_uses_recursive($component), true)) { + $component->schema($this->replaceFulfillmentTypeField($component->getChildComponents())); + } + } + + return $result; + } + private function fulfillmentTypeSelect(): Select { return Select::make('data.fulfillment_type') @@ -52,9 +125,23 @@ class ShippingMethodResourceExtension extends ResourceExtension ]) ->default('carrier') ->required() + ->visible(fn (Get $get) => $this->driverIsFulfillmentAmbiguous($get('../driver'))) ->helperText('Whether an order using this method is handed to a carrier, or collected by the customer in person.'); } + private function driverIsFulfillmentAmbiguous(?string $driver): bool + { + if (! $driver) { + return true; + } + + try { + return ! Shipping::driver($driver) instanceof DeclaresFulfillmentType; + } catch (InvalidArgumentException) { + return true; + } + } + /** * Extend the vendor's cart_total/weight charge_by Select with a third * "live" option — only offered when the currently selected driver @@ -127,6 +214,10 @@ class ShippingMethodResourceExtension extends ResourceExtension return $this->driverColumn(); } + if (method_exists($column, 'getName') && $column->getName() === 'name') { + return $this->nameColumn(); + } + return $column; }, $table->getColumns()) ); @@ -139,6 +230,21 @@ class ShippingMethodResourceExtension extends ResourceExtension ->formatStateUsing(fn ($state) => $this->driverLabel($state)); } + /** + * `name` is a locale-keyed JSON column (see Modules\Core\Shipping\ + * Support\ShippingMethodName's own docblock for why it needs manual + * decoding rather than a model cast). Uses ->state() rather than + * ->formatStateUsing(), which would otherwise have Filament iterate a + * would-be array state as a multi-value list (one formatted cell per + * locale) instead of a single string. + */ + private function nameColumn(): TextColumn + { + return TextColumn::make('name') + ->label('Name') + ->state(fn ($record) => ShippingMethodName::resolve($record)); + } + private function driverLabel(string $key): string { $driver = collect(Shipping::getSupportedDrivers())->get($key); diff --git a/src/Shipping/Support/FulfillmentType.php b/src/Shipping/Support/FulfillmentType.php new file mode 100644 index 0000000..84b6373 --- /dev/null +++ b/src/Shipping/Support/FulfillmentType.php @@ -0,0 +1,60 @@ +get($method->driver); + + if ($driver instanceof DeclaresFulfillmentType) { + return $driver->fulfillmentType(); + } + + return $method->data['fulfillment_type'] ?? 'carrier'; + } + + public static function isStorePickup(ShippingMethod $method): bool + { + return static::resolve($method) === 'store_pickup'; + } + + /** + * Whether the merchant-facing "Fulfillment type" Select should be + * shown at all for a given driver — hidden entirely for a driver that + * already declares its own fulfillment type, since there is no real + * decision left for the merchant to make. + */ + public static function isConfigurableFor(?string $driverKey): bool + { + if ($driverKey === null) { + return true; + } + + $driver = collect(Shipping::getSupportedDrivers())->get($driverKey); + + return ! $driver instanceof DeclaresFulfillmentType; + } +} diff --git a/src/Shipping/Support/ShippingMethodName.php b/src/Shipping/Support/ShippingMethodName.php new file mode 100644 index 0000000..572f6a1 --- /dev/null +++ b/src/Shipping/Support/ShippingMethodName.php @@ -0,0 +1,35 @@ + + * name` returns the raw JSON string, not a decoded array — this resolves + * it the same way Lunar\Base\Traits\HasTranslations::translate() would, + * shared by every rate driver that builds a Lunar\DataTypes\ShippingOption + * (Modules\Core\Shipping\Concerns\ResolvesFixedPricing, Modules\Core\ + * Shipping\Carriers\Acs\AcsRateDriver) plus the Filament table column. + */ +class ShippingMethodName +{ + public static function resolve(ShippingMethod $method, ?string $locale = null): ?string + { + $decoded = json_decode((string) $method->getRawOriginal('name'), true); + + if (! is_array($decoded)) { + return $method->getRawOriginal('name'); + } + + return Arr::get($decoded, $locale ?: app()->getLocale()) ?: Arr::first($decoded); + } +}