Compare commits

...
14 Commits
Author SHA1 Message Date
arvanitakis c7035d6782 Bump version to 0.20.0 2026-09-23 09:47:28 +03:00
arvanitakis 4c0974bf84 Feat: Updating Product Indexer, and Product Sort 2026-09-23 09:41:21 +03:00
arvanitakis 4e15d8ef8c Fix: Updating Wipe Catalog Command to force delete products instead of the soft delete 2026-09-22 21:17:35 +03:00
arvanitakis 1c7efc6e4d Feature: Adding Custom Fields to Products 2026-09-22 21:17:01 +03:00
arvanitakis 59c57b37fc Feat: Updating ShopifyExportImporter and WipeCatalogCommand to handle images 2026-09-22 15:29:03 +03:00
arvanitakis 37b49963f6 Feat: Adding Backfill Skus to the Migrate Import Job 2026-09-22 14:55:18 +03:00
arvanitakis 050204f063 Feature: Adding Wipe Catalog Command for all products 2026-09-22 14:36:57 +03:00
arvanitakis c0ae9d8996 Feat: Adding Purchasable to 'in_stock' when importing a new product 2026-09-22 13:48:08 +03:00
arvanitakis cc1cf6ea7f Feat: Displaying Draft Products, only when AppDebug = true 2026-09-22 13:46:26 +03:00
arvanitakis 0437057e5d Merge branch 'Quality-Updates' 2026-09-18 01:30:37 +03:00
arvanitakis 0c169daf55 Bump version to 0.19.0 2026-09-18 01:29:54 +03:00
arvanitakis 609a63c2f4 Feat: Tying Specific Methods with Carrier Drivers 2026-09-18 01:23:50 +03:00
arvanitakis 12aaa43f10 Fix: Updates to OrderFullfilmentServices and box now clients, order views and checkout services 2026-09-18 00:54:43 +03:00
arvanitakis dcdc998eee Feat: Updating Shipping Method with new variables, for correct box env vars 2026-09-18 00:52:38 +03:00
42 changed files with 1501 additions and 75 deletions
+191
View File
@@ -4,6 +4,197 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [0.20.0] - 2026-09-23
### Added
- `Modules\Core\Catalog\Support\ProductFilterBuilder::withVisibility()` — every storefront
product read (`ProductService::list()`/`getById()`/`getBySlug()`/`random()`/`facets()`/
`priceRange()`, and `ProductSearchService`) now excludes `status = "draft"` products unless
`APP_DEBUG` is true. Previously nothing filtered by status anywhere in this service — a draft
product was fully visible on the storefront in every environment, always.
- Per-product custom input fields (`Modules\Core\Catalog\Models\Product::$custom_fields`) — a
repeater on the product edit form lets a merchant define extra input a shopper fills in on
that product's page before adding it to cart (a reference photo upload, personalization
text, etc.), each field a `{key, type: text|textarea|file, label, required}` entry.
Deliberately not a Lunar `ProductOption`: an option's values are a fixed, admin-authored list
that define variants, which doesn't fit "the shopper uploads their own unique photo."
Required a new first-party `Product` model (registered via `ModelManifest::replace()`) purely
to add a cast and `$fillable` entry Lunar's own base model doesn't have for this column — see
that class's own docblock for two real Lunar-integration bugs this surfaced (below).
- `boboko:wipe-catalog` (`Modules\Core\Command\WipeCatalogCommand`) — irreversibly deletes every
Product and everything that only exists because of a product (variants, variant prices,
product-option assignments, images/media, associations, the `ImportMapping` rows tying them
back to an external source, the Meilisearch index), leaving catalog structure other products
could still reference untouched (ProductOption/ProductOptionValue definitions, Brands,
Collections, Tags, Customer Groups). Gated by an OTP emailed to a real Staff account (reusing
`Auth\Services\OtpService`, the same mechanism admin login already uses) plus typing the exact
product count back — not a plain yes/no confirm.
- `Modules\Core\Auth\Services\OtpService::generateAndSend()` gained an optional `$purpose`
parameter (default `'login'`, fully backward compatible) — `OtpMail` picks its subject/intro
copy from a small fixed set of known purposes, so a destructive-command confirmation code
reads as "Confirm: Wipe Catalog," not the login flow's "Your login code."
- `Modules\Core\Catalog\Services\SkuBackfillService` — the actual backfill logic behind
`boboko:catalog:backfill-skus`, extracted so `MigrateImport\RunMigrateImportJob` can also call
it automatically right after a Shopify import (gated on `$spec->source === 'shopify'`, the
only source that creates variants at all) — no separate manual step needed after a migration.
- `ProductSort::Popularity` — sorts by a new `order_count` field Meilisearch now indexes per
product (trailing-year, physical order lines only, aggregated across a product's variants) —
the same "popular" definition Lunar's own admin dashboard "Popular Products" widget already
uses. Not wired into the storefront's sort dropdown yet; callable directly via
`ProductService::list(sort: ProductSort::Popularity)`.
### Fixed
- `MigrateImport\Shopify\ShopifyExportImporter` created every variant with Lunar's own column
default `purchasable = 'always'` (purchasable regardless of stock) rather than respecting the
real `Variant Inventory Qty` the import itself provides — now explicitly set to `'in_stock'`.
Forward-only; does not retroactively touch variants from a prior import run.
- `WipeCatalogCommand::wipe()` used `chunkById()` while deleting rows inside the loop — a known
pitfall where `chunkById()` re-queries "id > lastSeenId" every iteration, so deleting rows
shrinks the table out from under it and can silently skip products that were never actually
deleted at all. Fixed by always re-querying the first N remaining rows instead of advancing
an id cursor, so every product is visited exactly once regardless of how many are deleted out
from under the query as it goes.
- `WipeCatalogCommand` called `delete()`, not `forceDelete()`, on `Product`/`ProductVariant` —
both use `SoftDeletes`, so an "irreversible" wipe only trashed rows, leaving them sitting in
the table. Combined with the `chunkById` bug above, this left ~185 zero-variant ghost
`Product` rows in practice, which then crashed the admin's own global search (Lunar's
`ProductResource::getGlobalSearchResultDetails()` assumes every returned product — trashed
ones deliberately included, by Lunar's own design — has at least one variant). Fixed to
`forceDelete()`; the existing ghost rows were removed directly (none had live order/cart
references). `wipe()` also now clears `'image'`-type `ImportMapping` rows, not just
`'product'`/`'variant'`.
- `ShopifyExportImporter::resolveOrImportImage()` trusted a cached `ImportMapping`'d `Media`
object unconditionally — now verifies the row still exists and is still attached to the
current product before reusing it, falling through to a fresh import/attach otherwise. Makes
a re-import robust to orphaned media regardless of what left them behind (e.g. a prior
`WipeCatalogCommand` run, before the fix above).
- Cart admin view (`Cart\Filament\Resources\CartResource\Pages\ViewCart`) 500'd
(`Lunar\Exceptions\MissingCurrencyPriceException`) for any cart still holding a line whose
purchasable no longer exists (e.g. after `boboko:wipe-catalog`) — `Cart::calculate()` now has
that exception caught, falling back to an uncalculated cart; every total field already
rendered `?->formatted() ?? '—'`, so the page degrades to showing "—" instead of a 500.
- Creating or editing a Payment Method offered "Capture mode" (Charge immediately / Hold now,
charge later) even for `cash-on-delivery`, whose driver has no `authorize()` method at
all — selecting "authorize" there would have fatally errored at checkout. Now hidden/
non-required unless the resolved driver implements `SupportsAuthorization`.
- `Lunar\Base\Traits\Searchable::indexer()` (and its sibling filterable/sortable-attribute
methods) resolve their configured indexer via `$config[self::class]` — but `self::class`
inside a trait method is a compile-time literal bound to whichever class first `use`s the
trait, so it always evaluates to `Lunar\Models\Product`, never a subclass, regardless of
which instance calls it. `config/lunar/search.php`'s `'indexers'` map must stay keyed by
`Lunar\Models\Product::class`, not the new `Product` subclass — keying it by the subclass
made the lookup miss entirely and silently fall back to a near-empty default indexer, wiping
every filterable/sortable attribute the index had. Caught live, reverted; documented in the
config file itself so it isn't repeated.
- `CustomerServiceProvider`/`CatalogServiceProvider` called `ModelManifest::replace()` directly
from `boot()` — `LunarServiceProvider` (lunarphp/core) calls `Facades\ModelManifest::
register()` from its OWN `boot()`, re-discovering every `Lunar\Models\*` class and silently
overwriting any `replace()` registered earlier in the provider boot order. Both now defer to
`$this->app->booted()`, which only runs once every provider's `boot()` has completed.
## [0.19.0] - 2026-09-18
### Added
- `Modules\Core\Payment\Contracts\RequiresFulfillmentType` — a payment driver can now declare
it only makes sense for one fulfillment type (carrier delivery vs. store pickup), the
payment-side mirror of `Shipping\Contracts\DeclaresFulfillmentType`. `CheckoutService::
getPaymentMethods()` excludes a method whose driver disagrees with the cart's currently
selected shipping method — `OfflinePaymentDriver` ("pay in store") now requires
`store_pickup`, `CashOnDeliveryPaymentDriver` requires `carrier`. Previously every enabled,
configured payment method was offered regardless of shipping choice, so a shopper picking a
courier delivery could still see "Pay in store" (no staff member present to take cash), and a
store-pickup shopper could see cash-on-delivery (meaningless — there is no delivery to collect
payment on). No constraint is imposed before a shipping option is selected.
- `Modules\Core\Payment\Events\PaymentDeferred` — dispatched by any payment driver whose
`Pending` result will never resolve via a later gateway callback (currently only
`CashOnDeliveryPaymentDriver`), distinct from a Stripe-style `Pending` that a webhook will
still resolve. Handled by the new `Modules\Core\Order\Listeners\
MarkOrderPlacedOnDeferredPayment`, which sets `Order::placed_at`, dispatches `OrderPlaced`,
and advances `status` past `awaiting_payment` — without ever touching `Order::paid`, which
still only flips via staff explicitly marking a COD order received.
- `Modules\Core\Order\Services\OrderPaymentResolutionService::resolveDeferredPayment()` — the
status-advance half of the above, reusing the same "advance past `awaiting_payment`" logic a
captured payment already uses.
- `Modules\Core\Checkout\Exceptions\NoShippingAddressException`.
- `Modules\Core\Shipping\Carriers\BoxNow\BoxNowClient::destinations()` — lists available Box
Now lockers (`GET /destinations`), backing a plain, self-hosted locker picker on checkout;
Box Now's own Destination Map JS widget only talks to their Production environment, making it
unusable while developing against Stage credentials.
- `config/shippingCarriers/boxnow.php`: `BOXNOW_PARTNER_ID` — issued alongside Box Now
credentials, consumed only by their client-side map widget, never by `BoxNowClient`'s own
REST authentication.
- A "Tracking history" list under each shipment on the order page (`Shipping\Extensions\
OrderShipmentsExtension`) — every recorded carrier checkpoint, oldest first, not just the
latest status.
- `Modules\Core\Review\Services\ReviewService` and `ReviewEvents\ReviewReplied` — extracted
from `ManageProductReviews`'s inline `$record->update()`, following the write-then-dispatch
pattern used everywhere else.
- `Modules\Core\Catalog\Services\StockService::decrementForOrder()` — extracted from
`DecrementStockOnOrderPlaced`, isolating the atomic stock-decrement SQL and Meilisearch
reindex from the listener itself.
- `Modules\Core\Order\Services\OrderStatusFlow::isValidTransition()` — the single source of
truth for "is this a legal next status," replacing several listeners' own hardcoded "only
fire from status X" comparisons.
### Fixed
- Cash-on-delivery orders were placed but never left `awaiting_payment`, were invisible in
customer order history, never decremented stock, and the storefront's own post-checkout
confirmation could never find them — `CashOnDeliveryPaymentDriver::pay()` returns `Pending`,
which dispatched no event at all, so nothing ever set `Order::placed_at` or advanced
`status`. Fixed by `PaymentDeferred`/`MarkOrderPlacedOnDeferredPayment` above.
- Staff marking a COD order "paid" (`OrderFulfillmentService::markPaid()`) flipped
`Order::paid`/`paid_at` but never recorded a `Transaction` row — no audit trail, and anything
reading `$order->transactions` (paid-amount displays included) saw nothing. Now records a
`capture` transaction via `TransactionRecorder`, the exact call site its own docblock had
already anticipated ("a future admin action ... can write a row the same way").
- A confirmed cash-on-delivery shipment dispatched via ACS or Box Now never actually told the
carrier to collect payment — `ShipmentRequest::$paymentMode`/`$amountToCollect` were defined
on the DTO but no caller ever populated them, permanently dead-coding both carriers' COD
branches (`AcsFulfillmentService`'s `Cod_Ammount`/`Cod_Payment_Way`, Box Now's
`amountToBeCollected`). `OrderViewExtension`'s "Create Shipment" action now derives both from
`OrderStatusFlow::isCod($order)` at dispatch time — never left to staff to remember.
- `Modules\Core\Shipping\Jobs\PollShipmentTrackingJob`: one shipment's tracking lookup failing
(a carrier 500, a malformed parcel response) aborted the rest of that carrier's shipments in
the same batch — now individually caught and reported per shipment.
- Every Box Now delivery request 400'd (`P405`, invalid phone number) for any customer whose
phone was stored in local Greek format rather than full international — `contactNumber` is
now normalized to `+30...` before every request.
- Creating a Box Now shipment 400'd (`P401`/`P402`) whenever `BOXNOW_ORIGIN_LOCATION_ID` or the
sender contact fields were unset — documented and confirmed against a live sandbox account.
- Selecting a Box Now locker at checkout, then making any unrelated address-form edit
afterward (even a delivery-instructions keystroke), silently discarded the locker choice —
`Lunar\Actions\Carts\AddAddress` deletes and recreates the cart's shipping address row on
every save, wiping whatever `meta` a prior save had written onto it.
`CheckoutService::setShippingAddress()` now carries the locker forward across that
recreation; `selectShippingOption()` clears it when switching away from Box Now, so a stale
locker never resurfaces if the shopper switches back later.
`Shipping\Extensions\OrderViewExtension`'s "Box Now locker ID" field is no longer locked
read-only once a customer choice exists — staff can override it.
- Creating a Box Now shipping method 500'd (`Array to string conversion` / invalid JSON insert)
— the vendor `ListShippingMethod` page's `CreateAction` builds its form inline, bypassing
`ShippingMethodResourceExtension`'s translated-name field entirely; `Filament\Pages\
ManageShippingRates`'s method picker and "Shipping Method" table column also queried/sorted
the now-JSON `name` column directly in SQL (`could not identify an ordering operator for type
json`), both resolved app-side instead.
- Creating or editing a Payment Method: `capture_mode` ("Charge immediately" / "Hold now,
charge later") was offered even for a driver with no `authorize()` method at all
(`CashOnDeliveryPaymentDriver`), which would have fatally errored at checkout had "authorize"
ever been selected — now hidden/non-required unless the driver implements
`SupportsAuthorization`. A spurious `validation.required` on the translated Name field, and
every new Payment Method silently saving at `position` 0 regardless of the intended
"last in the list" default — both traced to the same cause: an `Action::schema()` modal only
dehydrates fields backed by a real form component, so `fillForm()`'s defaults for `name`/
`position` were computed but never actually reached the saved record.
- `Modules\Core\Auth\Services\UserOtpService::generateAndSend()` now dispatches `UserCreated`
when a new `User` row is created — this event was previously never dispatched anywhere in
this package at all, despite listeners existing for it.
- Applied a deliberate queueing policy across every Order/Localization/Customer/Payment/
Catalog listener, judged case-by-case on "if the queue stalls for minutes/hours, does this
cause a real functional break, not just cosmetic staleness" — `RecordPaymentTransaction`,
`CompleteOrderOnPickedUp`, `CreateCustomerForUser`, and `DecrementStockOnOrderPlaced` stay
synchronous (a stalled queue would mean a real ordering violation or oversell risk); cache
flushes, activity logging, and carrier-checkpoint-driven fulfillment listeners are now queued.
## [0.18.1] - 2026-09-16 ## [0.18.1] - 2026-09-16
### Added ### Added
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "boboko/core", "name": "boboko/core",
"description": "Core module — authentication and shared panel behaviour", "description": "Core module — authentication and shared panel behaviour",
"type": "library", "type": "library",
"version": "0.18.1", "version": "0.20.0",
"autoload": { "autoload": {
"psr-4": { "psr-4": {
"Modules\\Core\\": "src/" "Modules\\Core\\": "src/"
+14
View File
@@ -13,12 +13,25 @@
| |
| Set these via environment variables — never commit real values. | Set these via environment variables — never commit real values.
| |
| Box Now has two environments (see their Partner API manual, section 2):
| Stage/Sandbox for testing, Production once live. Each has its own
| client_id/client_secret pair and its own base_url/location_api_url —
| there is no shared "switch an env var" flag, since stage credentials
| don't work against the production host or vice versa.
|
| BOXNOW_BASE_URL Root REST endpoint for delivery-requests/parcels. | BOXNOW_BASE_URL Root REST endpoint for delivery-requests/parcels.
| BOXNOW_LOCATION_API_URL Separate, faster endpoint for origins/destinations | BOXNOW_LOCATION_API_URL Separate, faster endpoint for origins/destinations
| lookups (Box Now recommends this over the main | lookups (Box Now recommends this over the main
| base URL for those two calls specifically). | base URL for those two calls specifically).
| BOXNOW_CLIENT_ID OAuth2 client id. | BOXNOW_CLIENT_ID OAuth2 client id.
| BOXNOW_CLIENT_SECRET OAuth2 client secret. | BOXNOW_CLIENT_SECRET OAuth2 client secret.
| BOXNOW_PARTNER_ID Numeric partnerId Box Now issues alongside your
| credentials. NOT used for REST API authentication
| (BoxNowClient authenticates with client_id/
| client_secret alone) — this is only consumed by
| the client-side Destination Map widget config
| (_bn_map_widget_config.partnerId), confirmed
| against Box Now's own WooCommerce plugin source.
| BOXNOW_ORIGIN_LOCATION_ID Your warehouse's Box Now locationId, used as | BOXNOW_ORIGIN_LOCATION_ID Your warehouse's Box Now locationId, used as
| the pickup origin on every delivery request. | the pickup origin on every delivery request.
| BOXNOW_SENDER_* Static sender contact details reused on every | BOXNOW_SENDER_* Static sender contact details reused on every
@@ -33,6 +46,7 @@ return [
'client_id' => env('BOXNOW_CLIENT_ID'), 'client_id' => env('BOXNOW_CLIENT_ID'),
'client_secret' => env('BOXNOW_CLIENT_SECRET'), 'client_secret' => env('BOXNOW_CLIENT_SECRET'),
'partner_id' => env('BOXNOW_PARTNER_ID'),
'origin_location_id' => env('BOXNOW_ORIGIN_LOCATION_ID'), 'origin_location_id' => env('BOXNOW_ORIGIN_LOCATION_ID'),
@@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Per-product, customer-authored input fields — a personalized-statue
* product needing a reference photo upload and an optional engraving
* textarea, for example. Deliberately NOT modeled as a Lunar ProductOption
* (see Modules\Core\Catalog\Contracts\ProductOptionTypeInterface's own
* docblock): an option's values are a fixed, admin-authored list that
* define variants (Red/Green/Blue) — a photo upload has no such list, it's
* unique per order, and creates no variant at all. This is a genuinely
* different concept that happens to configure on the same product page.
*
* Array of {key, type: 'text'|'textarea'|'file', label, required} — `key`
* is what a submitted answer is keyed by in CartLine/OrderLine.meta (both
* already have a `meta` json column — see Modules\Core\Cart\Services\
* CartService::addLine()'s own $meta parameter), not a new table, since
* this is small, rarely-queried per-product config, the same reasoning
* ShippingMethod.data/PaymentMethod.data already follow for their own
* per-row settings.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table(config('lunar.database.table_prefix').'products', function (Blueprint $table) {
$table->json('custom_fields')->nullable()->after('attribute_data');
});
}
public function down(): void
{
Schema::table(config('lunar.database.table_prefix').'products', function (Blueprint $table) {
$table->dropColumn('custom_fields');
});
}
};
+1 -1
View File
@@ -1,5 +1,5 @@
<p>Hi {{ $name }},</p> <p>Hi {{ $name }},</p>
<p>Your login code is:</p> <p>{{ $intro }}</p>
<p style="font-size: 2rem; font-weight: bold; letter-spacing: 0.25rem;">{{ $code }}</p> <p style="font-size: 2rem; font-weight: bold; letter-spacing: 0.25rem;">{{ $code }}</p>
+29 -2
View File
@@ -6,20 +6,47 @@ use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope; use Illuminate\Mail\Mailables\Envelope;
/**
* The one OTP email template for every use of Auth\Services\OtpService —
* not just admin login. A code confirming a destructive Artisan command
* (e.g. Command\WipeCatalogCommand) reuses the exact same generation/
* validation mechanism as login, but "Your login code" as the subject
* would be actively misleading for that — the recipient never initiated a
* login. $purpose is a small, fixed set of known keys (see
* COPY_BY_PURPOSE), not free text — a typo'd/unknown purpose falls back
* to 'login' rather than rendering a blank subject/intro.
*/
class OtpMail extends Mailable class OtpMail extends Mailable
{ {
private const COPY_BY_PURPOSE = [
'login' => [
'subject' => 'Your login code',
'intro' => 'Your login code is:',
],
'wipe-catalog' => [
'subject' => 'Confirm: Wipe Catalog',
'intro' => 'Someone requested to permanently delete every product in the catalog. If this was you, enter this code to confirm:',
],
];
public function __construct( public function __construct(
public readonly string $name, public readonly string $name,
public readonly string $code, public readonly string $code,
public readonly string $purpose = 'login',
) {} ) {}
public function envelope(): Envelope public function envelope(): Envelope
{ {
return new Envelope(subject: 'Your login code'); return new Envelope(subject: $this->copy()['subject']);
} }
public function content(): Content public function content(): Content
{ {
return new Content(view: 'core::auth.mail.otp'); return new Content(view: 'core::auth.mail.otp', with: ['intro' => $this->copy()['intro']]);
}
private function copy(): array
{
return self::COPY_BY_PURPOSE[$this->purpose] ?? self::COPY_BY_PURPOSE['login'];
} }
} }
+8 -2
View File
@@ -11,7 +11,13 @@ class OtpService
private const EXPIRY_MINUTES = 10; private const EXPIRY_MINUTES = 10;
private const CODE_LENGTH = 6; private const CODE_LENGTH = 6;
public function generateAndSend(string $email): bool /**
* $purpose is forwarded as-is to OtpMail, which only recognizes a
* fixed set of keys (see its own COPY_BY_PURPOSE) — an unrecognized
* value there just falls back to 'login' rather than failing here, so
* this method has nothing of its own to validate.
*/
public function generateAndSend(string $email, string $purpose = 'login'): bool
{ {
$staff = Staff::where('email', $email)->first(); $staff = Staff::where('email', $email)->first();
@@ -25,7 +31,7 @@ class OtpService
$staff->otp_expires_at = now()->addMinutes(self::EXPIRY_MINUTES); $staff->otp_expires_at = now()->addMinutes(self::EXPIRY_MINUTES);
$staff->save(); $staff->save();
Mail::to($staff->email)->send(new OtpMail($staff->first_name, $code)); Mail::to($staff->email)->send(new OtpMail($staff->first_name, $code, $purpose));
return true; return true;
} }
@@ -14,6 +14,7 @@ use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\Blade;
use Lunar\Admin\Filament\Resources\CustomerResource; use Lunar\Admin\Filament\Resources\CustomerResource;
use Lunar\Admin\Filament\Resources\ProductResource\Pages\EditProduct; use Lunar\Admin\Filament\Resources\ProductResource\Pages\EditProduct;
use Lunar\Exceptions\MissingCurrencyPriceException;
use Lunar\Models\Cart; use Lunar\Models\Cart;
use Lunar\Models\CartLine; use Lunar\Models\CartLine;
use Lunar\Models\ProductVariant; use Lunar\Models\ProductVariant;
@@ -47,6 +48,17 @@ class ViewCart extends ViewRecord
* own OrderItemsTable loads for an order's line items (`with(['purchasable'])`, * own OrderItemsTable loads for an order's line items (`with(['purchasable'])`,
* see vendor/lunarphp/lunar/.../OrderItemsTable::getDefaultTable()) — so * see vendor/lunarphp/lunar/.../OrderItemsTable::getDefaultTable()) — so
* rendering the product grid doesn't N+1 per line. * rendering the product grid doesn't N+1 per line.
*
* calculate() throws Lunar\Exceptions\MissingCurrencyPriceException
* (vendor PricingManager) the moment ANY line's purchasable has no
* price row for the cart's currency — including a line whose
* purchasable no longer exists at all (a deleted ProductVariant still
* referenced by cart_lines.purchasable_id), which 500'd this whole
* page rather than just leaving that one line unpriced. The Lines
* section below already guards every purchasable-derived field with
* `instanceof ProductVariant` and renders fine with $cart left
* uncalculated — subTotal/total/etc. simply won't be populated, which
* reads as a stale/pending state rather than a broken page.
*/ */
protected function resolveRecord(int|string $key): Cart protected function resolveRecord(int|string $key): Cart
{ {
@@ -58,7 +70,11 @@ class ViewCart extends ViewRecord
EloquentCollection::make($cart->lines->pluck('purchasable')->filter(fn ($p) => $p instanceof ProductVariant)) EloquentCollection::make($cart->lines->pluck('purchasable')->filter(fn ($p) => $p instanceof ProductVariant))
->loadMissing(['product.thumbnail', 'images', 'values']); ->loadMissing(['product.thumbnail', 'images', 'values']);
return $cart->calculate(); try {
return $cart->calculate();
} catch (MissingCurrencyPriceException) {
return $cart;
}
} }
public function infolist(Schema $schema): Schema public function infolist(Schema $schema): Schema
+7
View File
@@ -14,6 +14,7 @@ enum ProductSort: string
case PriceAsc = 'price_asc'; case PriceAsc = 'price_asc';
case PriceDesc = 'price_desc'; case PriceDesc = 'price_desc';
case Newest = 'newest'; case Newest = 'newest';
case Popularity = 'popularity';
public function toMeilisearchSort(): string public function toMeilisearchSort(): string
{ {
@@ -21,6 +22,12 @@ enum ProductSort: string
self::PriceAsc => 'price:asc', self::PriceAsc => 'price:asc',
self::PriceDesc => 'price:desc', self::PriceDesc => 'price:desc',
self::Newest => 'created_at:desc', self::Newest => 'created_at:desc',
// order_count — see Modules\Core\Catalog\Services\
// ProductIndexer::toSearchableArray()'s own docblock: the same
// trailing-year, physical-order-line-count definition Lunar's
// own admin dashboard "Popular Products" widget already uses,
// aggregated per product rather than per variant.
self::Popularity => 'order_count:desc',
}; };
} }
} }
+52
View File
@@ -0,0 +1,52 @@
<?php
namespace Modules\Core\Catalog\Models;
/**
* Registered via Lunar\Facades\ModelManifest::replace(Lunar\Models\
* Product::class, self::class) — see Providers\CatalogServiceProvider —
* purely to add a cast AND fillable entry for `custom_fields` (see the
* migration adding that column: database/migrations/
* ..._add_custom_fields_to_products_table.php). Without the fillable
* entry, Lunar\Models\Product's own $fillable allowlist (attribute_data,
* product_type_id, status, brand_id — custom_fields isn't in it) silently
* drops the field on every mass-assignment save (Filament's own
* $record->update($data)) — no error, no exception, the admin form shows
* the repeater's rows as saved right up until the next page load, when
* they're simply gone. Caught in practice.
*
* ModelManifest::replace() only changes what code resolving Product
* through the CONTRACT (app(Contracts\Product::class), Filament's own
* ProductResource — its $model is ProductContract::class, not the
* concrete class) or the morph map receives — it does NOT retroactively
* change what a hardcoded `Lunar\Models\Product::query()`/`::find()`
* elsewhere in this codebase (or Lunar's own internals, e.g. the
* scheduled Meilisearch reindex command — see CatalogServiceProvider,
* which references this subclass by name specifically so that path picks
* it up too) resolves to. Most of this codebase's existing Product
* references are plain type-hints (they accept whichever instance is
* handed to them, subclass included) or don't touch `custom_fields` at
* all, so they're unaffected either way.
*/
class Product extends \Lunar\Models\Product
{
// NOT `protected $casts = [...]` — that property assignment REPLACES
// the parent's own $casts array wholesale rather than merging with
// it (PHP class property redeclaration has no merge semantics), which
// would silently drop every cast Lunar\Models\Product already
// defines (attribute_data, status, etc.). mergeCasts() is Eloquent's
// own documented mechanism for a subclass adding to, not replacing,
// its parent's casts.
public function __construct(array $attributes = [])
{
parent::__construct($attributes);
$this->mergeCasts([
'custom_fields' => 'array',
]);
$this->mergeFillable([
'custom_fields',
]);
}
}
+26
View File
@@ -5,6 +5,7 @@ namespace Modules\Core\Catalog\Services;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Lunar\Models\Currency; use Lunar\Models\Currency;
use Lunar\Models\OrderLine;
use Lunar\Models\Price; use Lunar\Models\Price;
use Lunar\Models\Product; use Lunar\Models\Product;
use Lunar\Models\ProductVariant; use Lunar\Models\ProductVariant;
@@ -103,6 +104,7 @@ class ProductIndexer extends BaseProductIndexer
return [ return [
...parent::getSortableFields(), ...parent::getSortableFields(),
'price', 'price',
'order_count',
]; ];
} }
@@ -139,6 +141,12 @@ class ProductIndexer extends BaseProductIndexer
->all(); ->all();
$data['slugs'] = $model->urls->pluck('slug')->unique()->values()->all(); $data['slugs'] = $model->urls->pluck('slug')->unique()->values()->all();
$data['skus'] = $model->variants->pluck('sku')->filter()->unique()->values()->all(); $data['skus'] = $model->variants->pluck('sku')->filter()->unique()->values()->all();
// Only decoded correctly when $model is an instance of
// Modules\Core\Catalog\Models\Product (the custom_fields cast
// lives there, not on the base Lunar\Models\Product) — see
// CatalogServiceProvider's own comment on why the scheduled
// reindex command references that subclass by name specifically.
$data['custom_fields'] = $model->custom_fields ?? [];
$data['tags'] = $model->tags->pluck('value')->all(); $data['tags'] = $model->tags->pluck('value')->all();
$data['media'] = $model->media->map(fn (Media $media) => $this->mapMedia($media))->all(); $data['media'] = $model->media->map(fn (Media $media) => $this->mapMedia($media))->all();
$data['variants'] = $model->variants->map(fn (ProductVariant $variant) => $this->mapVariant($variant, $currency))->all(); $data['variants'] = $model->variants->map(fn (ProductVariant $variant) => $this->mapVariant($variant, $currency))->all();
@@ -155,6 +163,24 @@ class ProductIndexer extends BaseProductIndexer
$data['in_stock'] = $model->variants->contains( $data['in_stock'] = $model->variants->contains(
fn (ProductVariant $variant) => $variant->canBeFulfilledAtQuantity(1) fn (ProductVariant $variant) => $variant->canBeFulfilledAtQuantity(1)
); );
// Same "popular" definition as Lunar's own admin dashboard widget
// (Lunar\Admin\Filament\Widgets\Dashboard\Orders\
// PopularProductsTable) — order-line COUNT, not summed quantity,
// over the trailing year, physical lines only — just aggregated
// per PRODUCT here (across all its variants) rather than per
// variant/identifier, since a storefront "sort by popularity"
// ranks products, not individual variant SKUs. Necessarily as
// stale as any other reindex-time field here (in_stock, price) —
// there's no live equivalent without a query per page load.
$data['order_count'] = OrderLine::query()
->whereIn('purchasable_id', $model->variants->pluck('id'))
->where('purchasable_type', 'product_variant')
->where('type', 'physical')
->whereHas('order', fn ($query) => $query->whereBetween('placed_at', [
now()->subYear()->startOfDay(),
now()->endOfDay(),
]))
->count();
$data['recommendations'] = app(RecommendationService::class) $data['recommendations'] = app(RecommendationService::class)
->recommend($model) ->recommend($model)
->load(['media', 'variants.prices']) ->load(['media', 'variants.prices'])
+5 -2
View File
@@ -254,7 +254,10 @@ class ProductService
public function random(int $limit): array public function random(int $limit): array
{ {
$raw = Product::search('') $raw = Product::search('')
->options(['attributesToRetrieve' => ['id']]) ->options([
'attributesToRetrieve' => ['id'],
'filter' => $this->filterBuilder->withVisibility(),
])
->raw(); ->raw();
$ids = collect($raw['hits'] ?? [])->pluck('id')->shuffle()->take($limit)->values(); $ids = collect($raw['hits'] ?? [])->pluck('id')->shuffle()->take($limit)->values();
@@ -287,7 +290,7 @@ class ProductService
private function findAllWhere(string $filter, int $limit = 1000): array private function findAllWhere(string $filter, int $limit = 1000): array
{ {
$paginator = Product::search('') $paginator = Product::search('')
->options(['filter' => $filter]) ->options(['filter' => $this->filterBuilder->withVisibility($filter)])
->paginateRaw(perPage: $limit, page: 1); ->paginateRaw(perPage: $limit, page: 1);
return collect($this->localizer->hitsFrom($paginator)) return collect($this->localizer->hitsFrom($paginator))
@@ -0,0 +1,56 @@
<?php
namespace Modules\Core\Catalog\Services;
use Lunar\Models\ProductVariant;
/**
* Generates a SKU for every ProductVariant missing one — extracted out of
* Command\BackfillMissingSkusCommand (which becomes a thin CLI wrapper
* around this, keeping --dry-run/progress-bar concerns out of the
* reusable logic) so MigrateImport\RunMigrateImportJob can also call it
* directly, right after a Shopify import, with no CLI concerns at all.
*
* Format is "SKU-P{product_id}-V{variant_id}": deterministic and
* guaranteed unique without a uniqueness check, since product_id/
* variant_id already are. Only variants with a null `sku` are touched —
* not an importer bug when one shows up after a Shopify import, the
* source CSV rows genuinely had no `Variant SKU` value (see
* MigrateImport\Shopify\ShopifyExportImporter).
*/
class SkuBackfillService
{
/**
* @param ?callable(ProductVariant, string): void $onEach invoked
* once per variant with the sku about to be written (or, when
* $dryRun is true, that WOULD be written) — the command's own
* --dry-run listing and progress bar hook in here without this
* service knowing anything about console output.
* @return int the number of variants processed
*/
public function backfill(bool $dryRun = false, ?callable $onEach = null): int
{
$query = ProductVariant::query()->whereNull('sku');
$total = $query->count();
if ($total === 0) {
return 0;
}
$query->chunkById(500, function ($variants) use ($dryRun, $onEach) {
foreach ($variants as $variant) {
$sku = "SKU-P{$variant->product_id}-V{$variant->id}";
if (! $dryRun) {
$variant->update(['sku' => $sku]);
}
if ($onEach !== null) {
$onEach($variant, $sku);
}
}
});
return $total;
}
}
+31 -3
View File
@@ -10,6 +10,13 @@ use Modules\Core\Catalog\DTOs\ProductFilters;
* out of ProductService (where it originated, scoped to browsing/filtering * out of ProductService (where it originated, scoped to browsing/filtering
* without a search term) so ProductSearchService can apply the exact same * without a search term) so ProductSearchService can apply the exact same
* filter semantics to a text query too, rather than reimplementing it. * filter semantics to a text query too, rather than reimplementing it.
*
* Also the single place that composes the draft-visibility clause (see
* withVisibility()) — every Meilisearch `filter` string ProductService
* constructs, including the handful of ad-hoc ones that don't call build()
* at all (getById()/getBySlug()'s id lookup, random()'s id-only fetch),
* goes through this class so none of them can silently omit it the way a
* status filter was missing everywhere until now.
*/ */
class ProductFilterBuilder class ProductFilterBuilder
{ {
@@ -19,10 +26,10 @@ class ProductFilterBuilder
* ProductService::priceRange() excludes 'price' so a price slider's own * ProductService::priceRange() excludes 'price' so a price slider's own
* bounds don't shrink to whatever range is already selected on it. * bounds don't shrink to whatever range is already selected on it.
*/ */
public function build(?ProductFilters $filters, array $exclude = []): ?string public function build(?ProductFilters $filters, array $exclude = []): string
{ {
if ($filters === null) { if ($filters === null) {
return null; return $this->withVisibility();
} }
$clauses = Collection::make([ $clauses = Collection::make([
@@ -36,6 +43,27 @@ class ProductFilterBuilder
'inStockOnly' => $filters->inStockOnly ? 'in_stock = true' : null, 'inStockOnly' => $filters->inStockOnly ? 'in_stock = true' : null,
])->except($exclude)->filter(); ])->except($exclude)->filter();
return $clauses->isEmpty() ? null : $clauses->join(' AND '); return $this->withVisibility($clauses->isEmpty() ? null : $clauses->join(' AND '));
}
/**
* A draft product (status = 'draft', see Lunar\Filament\Resources\
* ProductResource's own status Select) is only ever visible while
* APP_DEBUG is true — a merchant/developer previewing an unfinished
* product locally or on a staging box, never a real storefront
* visitor. Every ProductService method that builds a Meilisearch
* `filter` string, build() included, calls this rather than passing
* $rawClause straight to Product::search() — the one seam that
* guarantees none of them can omit the visibility rule.
*
* Always returns a non-empty string (never null) — a bare
* 'status = "published"' is itself a complete, valid Meilisearch
* filter on its own when $rawClause is null.
*/
public function withVisibility(?string $rawClause = null): string
{
$visibility = config('app.debug') ? null : 'status = "published"';
return Collection::make([$visibility, $rawClause])->filter()->join(' AND ');
} }
} }
@@ -0,0 +1,18 @@
<?php
namespace Modules\Core\Checkout\Exceptions;
use RuntimeException;
/**
* Thrown by CheckoutService::selectBoxNowLocker() when the cart has no
* shipping address yet to attach the chosen locker's meta to — the
* storefront must call setShippingAddress() first.
*/
class NoShippingAddressException extends RuntimeException
{
public function __construct()
{
parent::__construct('Cannot select a Box Now locker before a shipping address is set.');
}
}
+133 -3
View File
@@ -10,6 +10,7 @@ use Lunar\Base\Addressable;
use Lunar\DataTypes\ShippingOption; use Lunar\DataTypes\ShippingOption;
use Lunar\Facades\ShippingManifest; use Lunar\Facades\ShippingManifest;
use Lunar\Models\Cart; use Lunar\Models\Cart;
use Lunar\Shipping\Models\ShippingMethod;
use Modules\Core\Cart\Services\CartService; use Modules\Core\Cart\Services\CartService;
use Modules\Core\Checkout\Events\BillingAddressSet; use Modules\Core\Checkout\Events\BillingAddressSet;
use Modules\Core\Checkout\Events\PaymentMethodSelected; use Modules\Core\Checkout\Events\PaymentMethodSelected;
@@ -17,12 +18,15 @@ use Modules\Core\Checkout\Events\RecoveryConsentSet;
use Modules\Core\Checkout\Events\ShippingAddressSet; use Modules\Core\Checkout\Events\ShippingAddressSet;
use Modules\Core\Checkout\Events\ShippingOptionSelected; use Modules\Core\Checkout\Events\ShippingOptionSelected;
use Modules\Core\Checkout\Exceptions\InvalidShippingOptionException; use Modules\Core\Checkout\Exceptions\InvalidShippingOptionException;
use Modules\Core\Checkout\Exceptions\NoShippingAddressException;
use Modules\Core\Checkout\Exceptions\TermsNotAcceptedException; use Modules\Core\Checkout\Exceptions\TermsNotAcceptedException;
use Modules\Core\Checkout\Exceptions\UnknownPaymentTypeException; use Modules\Core\Checkout\Exceptions\UnknownPaymentTypeException;
use Modules\Core\Payment\Contracts\RequiresFulfillmentType;
use Modules\Core\Payment\DTOs\PaymentResult; use Modules\Core\Payment\DTOs\PaymentResult;
use Modules\Core\Payment\Models\PaymentMethod; use Modules\Core\Payment\Models\PaymentMethod;
use Modules\Core\Payment\Services\PaymentDriverRegistry; use Modules\Core\Payment\Services\PaymentDriverRegistry;
use Modules\Core\Payment\Services\PaymentMethodCache; use Modules\Core\Payment\Services\PaymentMethodCache;
use Modules\Core\Shipping\Support\FulfillmentType;
/** /**
* Storefront-facing checkout operations, mirroring * Storefront-facing checkout operations, mirroring
@@ -49,9 +53,35 @@ class CheckoutService
private readonly PaymentMethodCache $paymentMethods, private readonly PaymentMethodCache $paymentMethods,
) {} ) {}
/**
* Lunar\Actions\Carts\AddAddress (behind Cart::setShippingAddress())
* always deletes the cart's existing shipping address row and inserts
* a brand new one — it has no notion of "update in place." Every field
* on the new row therefore starts blank, including `meta`, which is
* where selectBoxNowLocker() stores the shopper's chosen locker. Since
* the checkout page autosaves the address form on every field change
* (not just once), any edit made after picking a locker — even an
* unrelated one, like delivery instructions — silently wiped the
* locker choice by recreating the row out from under it.
*
* Carries the previous row's box_now_locker forward onto the new one
* so the two features don't stomp on each other, without needing
* Lunar's own AddAddress action to change. The old row's meta is read
* BEFORE Lunar deletes it, since afterward there's nothing left to
* read.
*/
public function setShippingAddress(array|Addressable $address): Cart public function setShippingAddress(array|Addressable $address): Cart
{ {
$cart = $this->cart->currentOrCreate()->setShippingAddress($address); $cartBefore = $this->cart->currentOrCreate();
$boxNowLocker = $cartBefore->shippingAddress?->meta['box_now_locker'] ?? null;
$cart = $cartBefore->setShippingAddress($address);
if ($boxNowLocker !== null) {
$newAddress = $cart->shippingAddress;
$newAddress->meta = [...($newAddress->meta?->toArray() ?? []), 'box_now_locker' => $boxNowLocker];
$newAddress->save();
}
Event::dispatch(new ShippingAddressSet($cart, $address)); Event::dispatch(new ShippingAddressSet($cart, $address));
@@ -141,15 +171,71 @@ class CheckoutService
$cart = $cartBefore->setShippingOption($option); $cart = $cartBefore->setShippingOption($option);
// Switching away from Box Now leaves a stale box_now_locker on the
// address's meta (see setShippingAddress()'s own docblock for why
// it survives address-row recreation) — irrelevant while a
// different method is selected, but wrong if the shopper later
// switches BACK to Box Now and it resurfaces as if still chosen,
// possibly for a locker that no longer exists/fits. Cleared here,
// the one place that knows the method just changed.
if ($identifier !== 'box-now') {
$address = $cart->shippingAddress;
if ($address && isset($address->meta['box_now_locker'])) {
$meta = $address->meta->toArray();
unset($meta['box_now_locker']);
$address->meta = $meta;
$address->save();
}
}
Event::dispatch(new ShippingOptionSelected($cart, $option)); Event::dispatch(new ShippingOptionSelected($cart, $option));
return $cart; return $cart;
} }
/**
* Records the shopper's chosen Box Now locker on the cart's shipping
* address (Cart\Addresses::shippingAddress()->meta['box_now_locker']),
* not on the cart itself — Lunar\Pipelines\Order\Creation\
* CreateOrderAddresses copies every cart address's full attributes
* (meta included) onto the new order address when the order is placed,
* so this is what Modules\Core\Shipping\Carriers\BoxNow\
* BoxNowFulfillmentService and Modules\Core\Shipping\Extensions\
* OrderViewExtension already expect to find at
* $order->shippingAddress->meta['box_now_locker']['locationId'].
*
* No validation against Box Now's own /destinations list here — this
* mirrors setShippingAddress()'s leniency (see its own docblock/the
* class-level note on required-field enforcement happening at the
* payment gate, not mid-checkout). An invalid/stale locationId still
* surfaces later, at BoxNowFulfillmentService::createShipment() time.
*
* @throws NoShippingAddressException if the cart has no shipping
* address yet
*/
public function selectBoxNowLocker(array $locker): Cart
{
$cart = $this->cart->currentOrCreate();
$address = $cart->shippingAddress;
if (! $address) {
throw new NoShippingAddressException();
}
$address->meta = [
...($address->meta?->toArray() ?? []),
'box_now_locker' => $locker,
];
$address->save();
return $cart;
}
/** /**
* Every payment method currently offered to the storefront, ordered by * Every payment method currently offered to the storefront, ordered by
* Modules\Core\Payment\Models\PaymentMethod::position — a row is * Modules\Core\Payment\Models\PaymentMethod::position — a row is
* offered only when ALL three checks pass, each meaning something * offered only when ALL four checks pass, each meaning something
* different to an admin diagnosing why a method isn't showing up (see * different to an admin diagnosing why a method isn't showing up (see
* docs/payments.md): * docs/payments.md):
* 1. `enabled` — an admin turned it on. * 1. `enabled` — an admin turned it on.
@@ -160,17 +246,61 @@ class CheckoutService
* vanished driver can never silently look "available"). * vanished driver can never silently look "available").
* 3. the resolved driver reports Configurable::isConfigured() — its * 3. the resolved driver reports Configurable::isConfigured() — its
* own runtime requirements (e.g. an API key) are met. * own runtime requirements (e.g. an API key) are met.
* 4. its driver's RequiresFulfillmentType (if it declares one)
* agrees with the cart's currently selected shipping method's own
* fulfillment type (Modules\Core\Shipping\Support\
* FulfillmentType::resolve()) — "Pay in store" offered alongside
* a courier delivery makes no sense (no staff member present at
* handoff to take cash), and cash-on-delivery alongside store
* pickup is equally meaningless (OfflinePaymentDriver already
* covers that in-person moment). A cart with no shipping option
* selected yet imposes no constraint here — every method is
* offered until a fulfillment type is actually known, the same
* leniency setShippingAddress()'s own docblock describes for
* required-field enforcement happening at the payment gate, not
* mid-checkout.
* *
* @return Collection<int, PaymentMethod> * @return Collection<int, PaymentMethod>
*/ */
public function getPaymentMethods(): Collection public function getPaymentMethods(): Collection
{ {
$fulfillmentType = $this->currentFulfillmentType();
return $this->paymentMethods->all() return $this->paymentMethods->all()
->filter(fn (PaymentMethod $method) => $method->enabled && $method->driver_missing_at === null) ->filter(fn (PaymentMethod $method) => $method->enabled && $method->driver_missing_at === null)
->filter(fn (PaymentMethod $method) => $this->paymentDrivers->resolve($method->driver)?->isConfigured() ?? false) ->filter(function (PaymentMethod $method) use ($fulfillmentType) {
$driver = $this->paymentDrivers->resolve($method->driver);
if (! $driver?->isConfigured()) {
return false;
}
if ($fulfillmentType === null || ! $driver instanceof RequiresFulfillmentType) {
return true;
}
return $driver->requiredFulfillmentType() === $fulfillmentType;
})
->values(); ->values();
} }
/**
* @return 'carrier'|'store_pickup'|null null when the cart has no
* shipping option selected yet
*/
private function currentFulfillmentType(): ?string
{
$identifier = $this->cart->currentOrCreate()->shippingAddress?->shipping_option;
if ($identifier === null) {
return null;
}
$method = ShippingMethod::where('code', $identifier)->first();
return $method ? FulfillmentType::resolve($method) : null;
}
/** /**
* Records which payment type the shopper picked (Cart::meta * Records which payment type the shopper picked (Cart::meta
* ['payment_method']) — read by Modules\Core\Payment\Pipelines\ * ['payment_method']) — read by Modules\Core\Payment\Pipelines\
+13 -22
View File
@@ -4,15 +4,13 @@ namespace Modules\Core\Command;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Lunar\Models\ProductVariant; use Lunar\Models\ProductVariant;
use Modules\Core\Catalog\Services\SkuBackfillService;
/** /**
* One-off backfill for variants the Shopify import left with a blank SKU — * CLI wrapper (--dry-run, a progress bar) around Catalog\Services\
* not an importer bug, the source CSV rows genuinely had no `Variant SKU` * SkuBackfillService — see that class's own docblock for the actual
* value (see Modules\MigrateImport\Shopify\ShopifyExportImporter) — so * backfill logic, also called automatically after a Shopify import (see
* this synthesizes one instead of re-running the import. Format is * MigrateImport\RunMigrateImportJob).
* "SKU-P{product_id}-V{variant_id}": deterministic and guaranteed unique
* without a uniqueness check, since product_id/variant_id already are.
* Only variants with a null `sku` are touched.
*/ */
class BackfillMissingSkusCommand extends Command class BackfillMissingSkusCommand extends Command
{ {
@@ -20,12 +18,11 @@ class BackfillMissingSkusCommand extends Command
protected $description = 'Generate a SKU for every product variant that is missing one'; protected $description = 'Generate a SKU for every product variant that is missing one';
public function handle(): void public function handle(SkuBackfillService $backfill): void
{ {
$dryRun = (bool) $this->option('dry-run'); $dryRun = (bool) $this->option('dry-run');
$query = ProductVariant::query()->whereNull('sku'); $total = ProductVariant::query()->whereNull('sku')->count();
$total = $query->count();
if ($total === 0) { if ($total === 0) {
$this->info('No variants are missing a SKU.'); $this->info('No variants are missing a SKU.');
@@ -38,19 +35,13 @@ class BackfillMissingSkusCommand extends Command
$bar = $this->output->createProgressBar($total); $bar = $this->output->createProgressBar($total);
$bar->start(); $bar->start();
$query->chunkById(500, function ($variants) use ($dryRun, $bar) { $backfill->backfill($dryRun, function (ProductVariant $variant, string $sku) use ($dryRun, $bar) {
foreach ($variants as $variant) { if ($dryRun) {
$sku = "SKU-P{$variant->product_id}-V{$variant->id}"; $this->newLine();
$this->line("Variant {$variant->id}: sku => {$sku}");
if ($dryRun) {
$this->newLine();
$this->line("Variant {$variant->id}: sku => {$sku}");
} else {
$variant->update(['sku' => $sku]);
}
$bar->advance();
} }
$bar->advance();
}); });
$bar->finish(); $bar->finish();
+199
View File
@@ -0,0 +1,199 @@
<?php
namespace Modules\Core\Command;
use Illuminate\Console\Command;
use Lunar\Models\Product;
use Modules\Core\Auth\Models\Staff;
use Modules\Core\Auth\Services\OtpService;
use Modules\Core\MigrateImport\Models\ImportMapping;
use function Laravel\Prompts\password;
use function Laravel\Prompts\text;
/**
* Irreversibly deletes every Product and everything that only exists
* because of a product — variants, variant prices, product-option value
* assignments, product images/media, product associations, the
* ImportMapping rows tying them back to an external source, and the
* Meilisearch product index. Deliberately does NOT touch catalog
* STRUCTURE other products could still reference: ProductOption/
* ProductOptionValue definitions ("Size", "Color" as reusable option
* types), Brands, Collections, Tags, Customer Groups — none of those are
* products, they're config a merchant would otherwise have to rebuild
* from scratch.
*
* Two gates a destructive, whole-catalog, irreversible operation
* warrants — deliberately NOT restricted to non-production on top of
* these; a real, legitimate use case is wiping a client's demo/seed
* catalog on a production database right before real launch, and the OTP
* below already proves the operator has real staff access, not just
* shell access to wherever `php artisan` happens to be runnable:
* 1. An OTP emailed to a real Staff account (reusing Auth\Services\
* OtpService — the exact mechanism admin login already uses).
* 2. Typing the literal product count back, not just "yes" — a plain
* confirm() is too easy to reflexively accept; forcing the operator
* to read and retype the actual number they're about to delete is a
* last check against running this against the wrong environment/
* database by mistake.
*
* Deletes via Eloquent model instances, not DB::table()->delete() —
* Product/ProductVariant use Spatie's InteractsWithMedia (see Lunar\Base\
* Traits\HasMedia), which only cleans up media files/rows on a real model
* `deleted` event, never on a raw query-builder delete.
*/
class WipeCatalogCommand extends Command
{
protected $signature = 'boboko:wipe-catalog {--email= : Staff email to send the confirmation code to}';
protected $description = 'Irreversibly delete every product, variant, and related catalog data';
public function handle(OtpService $otp): int
{
// withTrashed() — a prior soft-delete-only bug in this command
// (fixed in wipe() below) could leave ghost rows a plain count()
// would never see, silently reporting "nothing to do" while they
// sit there breaking other things (e.g. the admin's own global
// search, which assumes every returned product has variants).
$productCount = Product::withTrashed()->count();
if ($productCount === 0) {
$this->info('No products exist — nothing to do.');
return self::SUCCESS;
}
if (! $this->authorize($otp)) {
return self::FAILURE;
}
$this->warn("This will PERMANENTLY delete {$productCount} product(s) and everything that only exists because of them (variants, prices, images, product-option assignments, associations). This cannot be undone.");
$typed = text(label: "Type the product count ({$productCount}) to confirm");
if ($typed !== (string) $productCount) {
$this->error('Count did not match — aborted, nothing was deleted.');
return self::FAILURE;
}
$this->wipe();
$this->info("Deleted {$productCount} product(s) and all related data.");
return self::SUCCESS;
}
private function authorize(OtpService $otp): bool
{
$email = $this->option('email') ?? text(
label: 'Staff email to send a confirmation code to',
validate: fn (string $value) => Staff::where('email', $value)->exists()
? null
: 'No staff account with that email exists.',
);
if (! $otp->generateAndSend($email, purpose: 'wipe-catalog')) {
$this->error('Could not send a confirmation code to that email.');
return false;
}
$this->info("A confirmation code was sent to {$email}.");
$code = password(label: 'Enter the confirmation code');
if ($otp->validate($email, $code) === null) {
$this->error('Invalid or expired code — aborted, nothing was deleted.');
return false;
}
return true;
}
/**
* Every step below goes through a real Eloquent relation, never a raw
* table name — Lunar's own table prefix is configurable
* (config('lunar.database.table_prefix'), applied in BaseModel's
* constructor), so a hardcoded 'lunar_...' string would silently
* no-op on an install using a different one.
*
* Order matters: product_associations and the product/product_option
* pivot have a real FK to `products` but no ON DELETE CASCADE (both
* RESTRICT, Laravel's own default), so they're detached before the
* product/variant rows they reference — deleting a product that
* still has either would throw. ProductVariant's own `prices` (a
* plain morph, HasPrices trait — no FK constraint at all) would
* otherwise silently orphan rather than throw, so it's cleared the
* same way regardless. media_variant and product_option_value_
* product_variant DO cascade at the DB level (see their own
* migrations), so deleting the variant itself is enough for those two.
*
* Deliberately NOT chunkById() — that re-queries "id > lastSeenId"
* every iteration, but deleting rows inside the loop shrinks the
* table out from under it: any product whose id fell in a range
* chunkById() had already stepped past could be silently skipped and
* never actually deleted at all. Caught in practice — the first real
* run of this command left orphaned Media rows (Spatie's own
* deleteAllMedia(), fired from Product's `deleting` event, never ran
* for the skipped products) whose 'image' ImportMapping rows then
* caused a LATER Shopify re-import to silently reuse those now-
* orphaned Media objects instead of importing fresh ones — see
* MigrateImport\Shopify\ShopifyExportImporter::resolveOrImportImage()'s
* own docblock for that half of the same incident. Always re-querying
* the first N remaining rows (never advancing an id cursor) guarantees
* every product is actually visited exactly once, however many are
* deleted out from under the query as it goes.
*/
private function wipe(): void
{
ImportMapping::whereIn('source_type', ['product', 'variant', 'image'])->delete();
while (true) {
// withTrashed(): Product/ProductVariant both use SoftDeletes
// — a plain query would stop seeing a product the moment
// forceDelete() below actually removes it, which is fine, but
// WITHOUT withTrashed() here this loop would never even
// fetch a row that a previous, buggy run of this command
// (or any other code) had already soft-deleted without
// force-deleting it. Ghost rows like that are exactly what
// this command exists to remove.
$products = Product::withTrashed()
->with(['variants' => fn ($query) => $query->withTrashed(), 'associations', 'inverseAssociations'])
->limit(100)
->get();
if ($products->isEmpty()) {
break;
}
foreach ($products as $product) {
$product->associations()->delete();
$product->inverseAssociations()->delete();
$product->productOptions()->detach();
foreach ($product->variants as $variant) {
$variant->prices()->delete();
// NOT delete() — Product/ProductVariant both use
// SoftDeletes, and a plain delete() only sets
// deleted_at, leaving the row (and, for Product, its
// media) sitting in the table. This command's whole
// purpose is an irreversible wipe; a soft-deleted
// ghost row is the opposite of that. Caught in
// practice — a prior run's plain delete() left 185
// ghost Product rows with zero real variants, which
// then crashed the admin's own global search
// (Lunar\Admin\Filament\Resources\ProductResource::
// getGlobalSearchResultDetails() assumes
// $record->variants->first() is never null).
$variant->forceDelete();
}
$product->forceDelete();
}
}
Product::removeAllFromSearch();
}
}
+12 -1
View File
@@ -7,6 +7,7 @@ use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Modules\Core\Catalog\Services\SkuBackfillService;
class RunMigrateImportJob implements ShouldQueue class RunMigrateImportJob implements ShouldQueue
{ {
@@ -20,9 +21,19 @@ class RunMigrateImportJob implements ShouldQueue
) { ) {
} }
public function handle(): void public function handle(SkuBackfillService $skuBackfill): void
{ {
$importer = ImporterFactory::make($this->spec); $importer = ImporterFactory::make($this->spec);
$importer->import($this->spec); $importer->import($this->spec);
// Only Shopify's importer creates ProductVariant rows at all (see
// Shopify\ShopifyExportImporter) — JudgeMe never touches products,
// so running this for that source would just be a guaranteed
// no-op query every time. Source CSV rows genuinely can have no
// `Variant SKU` value; see SkuBackfillService's own docblock for
// why that's synthesized rather than treated as an importer bug.
if ($this->spec->source === 'shopify') {
$skuBackfill->backfill();
}
} }
} }
@@ -163,6 +163,12 @@ class ShopifyExportImporter implements Importer
$variant->sku = trim((string) ($row['Variant SKU'] ?? '')) ?: null; $variant->sku = trim((string) ($row['Variant SKU'] ?? '')) ?: null;
$variant->stock = (int) ($row['Variant Inventory Qty'] ?? 0); $variant->stock = (int) ($row['Variant Inventory Qty'] ?? 0);
$variant->shippable = filter_var($row['Variant Requires Shipping'] ?? 'true', FILTER_VALIDATE_BOOLEAN); $variant->shippable = filter_var($row['Variant Requires Shipping'] ?? 'true', FILTER_VALIDATE_BOOLEAN);
// Lunar's own column default is 'always' (purchasable regardless of
// stock) — wrong for an imported catalogue, whose Variant Inventory
// Qty is real, meaningful stock data. 'in_stock' makes purchasability
// actually respect it (see Modules\Core\Catalog\Services\
// StockService's own docblock on the three purchasable values).
$variant->purchasable = 'in_stock';
$variant->save(); $variant->save();
ImportMapping::record(self::SOURCE, 'variant', $externalId, $variant); ImportMapping::record(self::SOURCE, 'variant', $externalId, $variant);
@@ -239,7 +245,23 @@ class ShopifyExportImporter implements Importer
$existing = ImportMapping::resolve(self::SOURCE, 'image', $externalId); $existing = ImportMapping::resolve(self::SOURCE, 'image', $externalId);
if ($existing instanceof Media) { // ImportMapping is a durable record of "we already imported this,"
// but the Media row it points at can go stale — e.g. Command\
// WipeCatalogCommand deletes every Product (media included, via
// Spatie's own model-delete cleanup) without knowing this mapping
// exists, since the mapping ISN'T scoped to a single Product to
// clean up alongside it. Re-running an import afterward used to
// trust the cached Media object unconditionally — it still existed
// as a PHP object even though its underlying row (and file) were
// long gone, so every re-imported product silently got zero
// media, no error, no warning. Falls through to a fresh import
// whenever the mapping doesn't resolve to a real, still-attached
// Media row.
if ($existing instanceof Media
&& Media::whereKey($existing->getKey())->exists()
&& $existing->model_type === $product->getMorphClass()
&& (int) $existing->model_id === $product->id
) {
return $existing; return $existing;
} }
@@ -0,0 +1,66 @@
<?php
namespace Modules\Core\Order\Listeners;
use Illuminate\Support\Facades\Event;
use Lunar\Models\Order;
use Modules\Core\Checkout\Events\OrderPlaced;
use Modules\Core\Order\Services\OrderPaymentResolutionService;
use Modules\Core\Payment\Events\PaymentDeferred;
/**
* Deliberately NOT queued — the storefront's own post-checkout
* confirmation page (Modules\Core\Checkout\Http\Controllers\
* CheckoutController::orderStatus()/confirmation(), per docs/checkout.md)
* looks up the order by placed_at being set immediately after
* initiatePayment() returns; a stalled queue would show the shopper a
* blank/failed confirmation for an order that, in the database, already
* exists and was genuinely placed. Same reasoning as
* DecrementStockOnOrderPlaced staying synchronous — this is the listener
* that makes DecrementStockOnOrderPlaced fire at all for a COD order (see
* OrderServiceProvider: OrderPlaced => DecrementStockOnOrderPlaced),
* so queueing this one would just move the same stock-oversell risk one
* hop earlier.
*
* A thin reactor, same shape as ApplyResolvedPaymentStatus — the actual
* decisions ("this order counts as placed the moment a deferred-payment
* driver resolves, independent of Order::paid" and "such an order also
* has nothing to sit at awaiting_payment for") live in PaymentDeferred's
* and OrderPaymentResolutionService::resolveDeferredPayment()'s own
* docblocks, re-confirmed with the user; this only extracts the order id
* and applies both, guarded against a duplicate/replayed event the same
* way OrderPaymentResolutionService::resolveCaptureOrAuthorization() is.
*
* Without the status advance below, a COD order was left sitting at
* 'awaiting_payment' forever — placed_at/OrderPlaced alone fixed order
* visibility and stock decrement, but nothing ever moved `status` off its
* initial value, since resolveCaptureOrAuthorization() only does that for
* an actual capture. Caught and fixed after the fact.
*/
class MarkOrderPlacedOnDeferredPayment
{
public function __construct(
private readonly OrderPaymentResolutionService $resolution,
) {}
public function handle(PaymentDeferred $event): void
{
$orderId = $event->context['order_id'] ?? null;
if ($orderId === null) {
return;
}
$order = Order::findOrFail($orderId);
$this->resolution->resolveDeferredPayment($order, self::class);
if (! blank($order->placed_at)) {
return;
}
$order->update(['placed_at' => now()]);
Event::dispatch(new OrderPlaced($order));
}
}
@@ -8,6 +8,8 @@ use Modules\Core\Order\DTOs\OrderFulfillmentResult;
use Modules\Core\Order\Events\OrderPickedUp; use Modules\Core\Order\Events\OrderPickedUp;
use Modules\Core\Order\Events\OrderReadyForDispatch; use Modules\Core\Order\Events\OrderReadyForDispatch;
use Modules\Core\Order\Events\OrderReadyForPickup; use Modules\Core\Order\Events\OrderReadyForPickup;
use Modules\Core\Payment\DTOs\PaymentResult;
use Modules\Core\Payment\Enums\PaymentResultStatus;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface; use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\DTOs\ShipmentRequest; use Modules\Core\Shipping\DTOs\ShipmentRequest;
use Throwable; use Throwable;
@@ -31,6 +33,7 @@ class OrderFulfillmentService
public function __construct( public function __construct(
private readonly OrderStatusWriter $writer, private readonly OrderStatusWriter $writer,
private readonly OrderStatusFlow $flow, private readonly OrderStatusFlow $flow,
private readonly TransactionRecorder $transactions,
) {} ) {}
public function markReady(Order $order): OrderFulfillmentResult public function markReady(Order $order): OrderFulfillmentResult
@@ -121,6 +124,39 @@ class OrderFulfillmentService
return OrderFulfillmentResult::failure('This order cannot be marked paid right now.'); return OrderFulfillmentResult::failure('This order cannot be marked paid right now.');
} }
// canMarkPaid() only ever returns true for an order whose payment
// method resolves to the cash-on-delivery DRIVER (see
// OrderStatusFlow::isCod(), which checks PaymentMethod::driver,
// never the merchant-chosen `type` slug directly — a store could
// name that method "cod", "pay-on-delivery", anything). Such an
// order never runs through Payment's pay()/authorize() flow at
// checkout, so nothing else records a Transaction for it. Money
// changes hands right here, at this click, so this is the one
// place that write can happen; there is no earlier Payment event
// to hang it off of the way Modules\Core\Order\Listeners\
// RecordPaymentTransaction does for a gateway driver. See
// TransactionRecorder's own docblock — it already anticipated
// exactly this "manually-triggered ... from Filament" call site.
//
// $driver below is the payment method's own `type` slug (whatever
// the merchant named it, e.g. 'cash-on-delivery' or 'cod') —
// Transaction.driver's established meaning everywhere else in this
// codebase (see RecordPaymentTransaction/TransactionRecorder's own
// docblocks) is that type key, never the underlying driver CLASS.
// No fallback guess here: CheckoutService::initiatePayment() always
// writes Order.meta['payment_method'] before charging, and
// canMarkPaid() already guarantees this order got that far.
$this->transactions->record(
$order,
type: 'capture',
driver: (string) $order->meta['payment_method'],
result: new PaymentResult(
status: PaymentResultStatus::Succeeded,
reference: 'cod-manual-'.$order->id,
amount: $order->total,
),
);
$this->writer->markPaid($order, self::class.'::markPaid'); $this->writer->markPaid($order, self::class.'::markPaid');
return OrderFulfillmentResult::success('Order marked as paid.'); return OrderFulfillmentResult::success('Order marked as paid.');
@@ -52,6 +52,24 @@ class OrderPaymentResolutionService
} }
} }
/**
* A deferred-capture payment (currently only cash-on-delivery — see
* Payment\Events\PaymentDeferred's own docblock): no money has moved,
* so unlike resolveCaptureOrAuthorization() this never calls
* $writer->markPaid() — Order::paid stays false until staff explicitly
* mark it received. But per OrderStatusFlow's own docblock, payment
* method never affects the status SEQUENCE at all — a COD order has
* nothing to "await" at checkout (no payment attempt happens), so
* 'awaiting_payment' is simply the wrong first status for it. Reuses
* the exact same advancePastAwaitingPayment() a capture uses, since
* the status-sequence logic itself doesn't differ by payment method,
* only whether `paid` also flips alongside it.
*/
public function resolveDeferredPayment(Order $order, string $causeClass): void
{
$this->advancePastAwaitingPayment($order, $causeClass);
}
/** /**
* Requires the refund Transaction row to already exist (Modules\Core\ * Requires the refund Transaction row to already exist (Modules\Core\
* Order\Listeners\RecordPaymentTransaction must run first — see * Order\Listeners\RecordPaymentTransaction must run first — see
@@ -0,0 +1,38 @@
<?php
namespace Modules\Core\Payment\Contracts;
/**
* Optional contract a payment driver implements to declare it only makes
* sense for one fulfillment type — the payment-side mirror of
* Modules\Core\Shipping\Contracts\DeclaresFulfillmentType. Two concrete
* cases exist today, both hardcoded facts about the driver rather than a
* merchant configuration choice:
* - OfflinePaymentDriver ("pay in store," cash-in-hand) only makes
* sense when the shopper collects in person — meaningless for a
* carrier delivery, where no staff member is present to take the
* cash.
* - CashOnDeliveryPaymentDriver only makes sense when a carrier
* physically hands over the parcel and collects payment at that
* moment — meaningless for store pickup, which already has
* OfflinePaymentDriver for exactly that in-person moment.
*
* A driver that doesn't implement this (Stripe, bank transfer) has no
* fulfillment-type constraint — offered regardless of the cart's
* currently selected shipping method's fulfillment type.
*
* Read by Modules\Core\Checkout\Services\CheckoutService::
* getPaymentMethods(), which excludes a method whose driver implements
* this and disagrees with the cart's current fulfillment type (via
* Modules\Core\Shipping\Support\FulfillmentType::resolve() on the
* currently selected ShippingMethod). A cart with no shipping option
* selected yet imposes no constraint — every method is offered until a
* fulfillment type is actually known.
*/
interface RequiresFulfillmentType
{
/**
* @return 'carrier'|'store_pickup'
*/
public function requiredFulfillmentType(): string;
}
@@ -5,9 +5,11 @@ namespace Modules\Core\Payment\Drivers;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Lunar\DataTypes\Price; use Lunar\DataTypes\Price;
use Modules\Core\Payment\Contracts\Configurable; use Modules\Core\Payment\Contracts\Configurable;
use Modules\Core\Payment\Contracts\RequiresFulfillmentType;
use Modules\Core\Payment\Contracts\SupportsPay; use Modules\Core\Payment\Contracts\SupportsPay;
use Modules\Core\Payment\DTOs\PaymentResult; use Modules\Core\Payment\DTOs\PaymentResult;
use Modules\Core\Payment\Enums\PaymentResultStatus; use Modules\Core\Payment\Enums\PaymentResultStatus;
use Modules\Core\Payment\Events\PaymentDeferred;
/** /**
* Cash-on-delivery/cash-on-pickup — the shopper pays staff in person, at * Cash-on-delivery/cash-on-pickup — the shopper pays staff in person, at
@@ -31,20 +33,46 @@ use Modules\Core\Payment\Enums\PaymentResultStatus;
* marking it received (Modules\Core\Order\Services\ * marking it received (Modules\Core\Order\Services\
* OrderFulfillmentService::markPaid()), offered by the single "Update * OrderFulfillmentService::markPaid()), offered by the single "Update
* Status" action at any time, independent of status. * Status" action at any time, independent of status.
*
* Despite returning Pending, this order IS fully placed the moment pay()
* returns — unlike a Stripe 3-D Secure Pending, nothing will ever resolve
* this into a later PaymentCaptured/PaymentAuthorized (COD has no gateway
* callback at all). Without PaymentDeferred, no listener ever set
* Order::placed_at for a COD order: invisible in customer order history,
* no stock decrement (Modules\Core\Order\Listeners\
* DecrementStockOnOrderPlaced only reacts to Checkout\Events\OrderPlaced),
* and the storefront's own post-checkout confirmation could never find it
* — a real bug, not a hypothetical, caught and fixed after the fact. See
* PaymentDeferred's own docblock for the full reasoning.
*/ */
class CashOnDeliveryPaymentDriver implements Configurable, SupportsPay class CashOnDeliveryPaymentDriver implements Configurable, SupportsPay, RequiresFulfillmentType
{ {
public function isConfigured(): bool public function isConfigured(): bool
{ {
return true; return true;
} }
/**
* "On delivery" is the operative word — a carrier physically hands
* over the parcel and collects payment at that moment. Meaningless
* for store pickup, which already has OfflinePaymentDriver for the
* equivalent in-person moment.
*/
public function requiredFulfillmentType(): string
{
return 'carrier';
}
public function pay(string $type, Price $amount, array $data = [], array $context = []): PaymentResult public function pay(string $type, Price $amount, array $data = [], array $context = []): PaymentResult
{ {
return new PaymentResult( $result = new PaymentResult(
status: PaymentResultStatus::Pending, status: PaymentResultStatus::Pending,
reference: 'cod-'.Str::uuid(), reference: 'cod-'.Str::uuid(),
amount: $amount, amount: $amount,
); );
PaymentDeferred::dispatch($type, $result, $context);
return $result;
} }
} }
+12 -1
View File
@@ -5,6 +5,7 @@ namespace Modules\Core\Payment\Drivers;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Lunar\DataTypes\Price; use Lunar\DataTypes\Price;
use Modules\Core\Payment\Contracts\Configurable; use Modules\Core\Payment\Contracts\Configurable;
use Modules\Core\Payment\Contracts\RequiresFulfillmentType;
use Modules\Core\Payment\Contracts\SupportsPay; use Modules\Core\Payment\Contracts\SupportsPay;
use Modules\Core\Payment\DTOs\PaymentResult; use Modules\Core\Payment\DTOs\PaymentResult;
use Modules\Core\Payment\Enums\PaymentResultStatus; use Modules\Core\Payment\Enums\PaymentResultStatus;
@@ -25,7 +26,7 @@ use Modules\Core\Payment\Events\PaymentCaptured;
* none) purely so PaymentCaptured, and anything downstream keying on it, * none) purely so PaymentCaptured, and anything downstream keying on it,
* have something to identify this attempt by. * have something to identify this attempt by.
*/ */
class OfflinePaymentDriver implements Configurable, SupportsPay class OfflinePaymentDriver implements Configurable, SupportsPay, RequiresFulfillmentType
{ {
/** /**
* Always true — no external dependency to be missing. * Always true — no external dependency to be missing.
@@ -35,6 +36,16 @@ class OfflinePaymentDriver implements Configurable, SupportsPay
return true; return true;
} }
/**
* Cash-in-hand requires a staff member physically present to take the
* payment — meaningless for a carrier delivery, where no such person
* exists at handoff.
*/
public function requiredFulfillmentType(): string
{
return 'store_pickup';
}
public function pay(string $type, Price $amount, array $data = [], array $context = []): PaymentResult public function pay(string $type, Price $amount, array $data = [], array $context = []): PaymentResult
{ {
$reference = 'offline-'.Str::uuid(); $reference = 'offline-'.Str::uuid();
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace Modules\Core\Payment\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Modules\Core\Payment\DTOs\PaymentResult;
/**
* "This order has no money to collect yet, and never will via a gateway
* callback — nothing further will ever resolve this PaymentResult's
* Pending status into Captured/Authorized." Distinct from a Stripe-style
* Pending (3-D Secure, still resolving asynchronously via a later webhook
* or client-side confirmation) — that case correctly dispatches nothing
* yet, since PaymentCaptured/PaymentAuthorized WILL still follow once
* resolved.
*
* Dispatched by Modules\Core\Payment\Drivers\CashOnDeliveryPaymentDriver::
* pay() the moment it returns Pending — a COD order is fully placed at
* that instant, with reconciliation (Order::paid) happening independently,
* anywhere from same-day to months later, entirely outside any gateway's
* knowledge. Any other current or future "deferred capture, no gateway
* callback" driver dispatches this the same way, rather than each
* reinventing its own "mark placed" event.
*
* Handled by Modules\Core\Order\Listeners\MarkOrderPlacedOnDeferredPayment
* — sets ONLY Order::placed_at and fires Checkout\Events\OrderPlaced.
* Deliberately does not touch Order::paid/paid_at (see
* OrderStatusWriter::markPaid(), the only path that ever does) or create a
* Transaction row (RecordPaymentTransaction listens to PaymentCaptured/
* PaymentAuthorized/PaymentVoided/PaymentRefunded only — correctly not
* this event, since no money has moved and there is nothing to record
* yet).
*/
class PaymentDeferred
{
use Dispatchable;
/**
* @param array<string, mixed> $context
*/
public function __construct(
public readonly string $type,
public readonly PaymentResult $result,
public readonly array $context = [],
) {}
}
@@ -3,10 +3,13 @@
namespace Modules\Core\Payment\Filament\Resources; namespace Modules\Core\Payment\Filament\Resources;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Resources\Resource; use Filament\Resources\Resource;
use Filament\Schemas\Components\Component; use Filament\Schemas\Components\Component;
use Filament\Schemas\Components\Utilities\Get;
use InvalidArgumentException;
use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\ToggleColumn; use Filament\Tables\Columns\ToggleColumn;
@@ -14,6 +17,7 @@ use Filament\Tables\Table;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use Lunar\Admin\Support\Forms\Components\TranslatedText; use Lunar\Admin\Support\Forms\Components\TranslatedText;
use Modules\Core\Payment\Contracts\Configurable; use Modules\Core\Payment\Contracts\Configurable;
use Modules\Core\Payment\Contracts\SupportsAuthorization;
use Modules\Core\Payment\Events\PaymentMethodsReordered; use Modules\Core\Payment\Events\PaymentMethodsReordered;
use Modules\Core\Payment\Filament\Resources\PaymentMethodResource\Pages\ListPaymentMethods; use Modules\Core\Payment\Filament\Resources\PaymentMethodResource\Pages\ListPaymentMethods;
use Modules\Core\Payment\Models\PaymentMethod; use Modules\Core\Payment\Models\PaymentMethod;
@@ -144,7 +148,31 @@ class PaymentMethodResource extends Resource
]) ])
->default('pay') ->default('pay')
->live() ->live()
->required(), // Only meaningful for a driver that actually implements
// SupportsAuthorization — CheckoutService::initiatePayment()
// calls $driver->authorize() when capture_mode is
// "authorize", which fatals on a driver missing that method
// entirely (e.g. CashOnDeliveryPaymentDriver, which only
// ever implements SupportsPay: the shopper pays staff in
// person, at an unknown future moment — there is no
// "hold now, settle later" operation to offer for that at
// all). Hidden rather than merely disabled, since a
// hidden field is also excluded from validation/dehydration
// — required() below would otherwise still block saving.
->visible(fn (Get $get) => static::driverSupportsAuthorization($get('driver')))
->required(fn (Get $get) => static::driverSupportsAuthorization($get('driver'))),
// Every OTHER fillForm() value not backed by a real component
// here is silently dropped — an Action::schema() modal only
// dehydrates fields present in its own schema, unlike a
// resource's form(); ListPaymentMethods::getHeaderActions()'s
// CreateAction::fillForm() used to set 'position' this same
// way and it never reached PaymentMethodService::create(),
// so every new method saved with the column's raw DB default
// (0) regardless of what fillForm() computed. Hidden here
// purely so it actually dehydrates; the table's own
// reorderable('position') drag-and-drop remains the real
// staff-facing way to change it afterward.
Hidden::make('position'),
]; ];
} }
@@ -153,9 +181,23 @@ class PaymentMethodResource extends Resource
return Select::make('driver') return Select::make('driver')
->label('Driver') ->label('Driver')
->options(fn () => app(PaymentDriverRegistry::class)->labels()) ->options(fn () => app(PaymentDriverRegistry::class)->labels())
->live()
->required(); ->required();
} }
private static function driverSupportsAuthorization(?string $driver): bool
{
if (! $driver) {
return true;
}
try {
return app(PaymentDriverRegistry::class)->resolve($driver) instanceof SupportsAuthorization;
} catch (InvalidArgumentException) {
return true;
}
}
public static function getPages(): array public static function getPages(): array
{ {
return [ return [
@@ -180,7 +222,7 @@ class PaymentMethodResource extends Resource
->icon('heroicon-o-pencil-square') ->icon('heroicon-o-pencil-square')
->schema(static::getFormComponents()) ->schema(static::getFormComponents())
->fillForm(fn (PaymentMethod $record) => $record->only([ ->fillForm(fn (PaymentMethod $record) => $record->only([
'name', 'type', 'driver', 'capture_mode', 'name', 'type', 'driver', 'capture_mode', 'position',
])) ]))
->action(fn (PaymentMethod $record, array $data) => app(PaymentMethodService::class)->update($record, $data)); ->action(fn (PaymentMethod $record, array $data) => app(PaymentMethodService::class)->update($record, $data));
} }
@@ -5,6 +5,7 @@ namespace Modules\Core\Payment\Filament\Resources\PaymentMethodResource\Pages;
use Filament\Actions\CreateAction; use Filament\Actions\CreateAction;
use Filament\Actions; use Filament\Actions;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
use Lunar\Models\Language;
use Modules\Core\Payment\Filament\Resources\PaymentMethodResource; use Modules\Core\Payment\Filament\Resources\PaymentMethodResource;
use Modules\Core\Payment\Models\PaymentMethod; use Modules\Core\Payment\Models\PaymentMethod;
use Modules\Core\Payment\Services\PaymentMethodService; use Modules\Core\Payment\Services\PaymentMethodService;
@@ -22,6 +23,15 @@ class ListPaymentMethods extends ListRecords
'position' => (PaymentMethod::max('position') ?? 0) + 1, 'position' => (PaymentMethod::max('position') ?? 0) + 1,
'enabled' => false, 'enabled' => false,
'data' => [], 'data' => [],
// TranslatedText's own default() (getLanguageDefaults())
// never reaches this mounted action's initial state —
// unlike a resource's own form(), an Action::schema()
// modal starts from exactly what fillForm() returns, so
// `name` was landing as null rather than the expected
// per-locale array, and every locale's sub-input
// silently failed to bind to it (required() on the
// default locale then correctly rejected the null).
'name' => Language::pluck('code')->mapWithKeys(fn (string $code) => [$code => ''])->all(),
]) ])
// Every PaymentMethod write goes through PaymentMethodService // Every PaymentMethod write goes through PaymentMethodService
// — see PaymentMethodResource's own docblock — so this // — see PaymentMethodResource's own docblock — so this
+55 -4
View File
@@ -5,12 +5,15 @@ namespace Modules\Core\Providers;
use Illuminate\Console\Scheduling\Schedule; use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Lunar\Models\Product; use Lunar\Facades\ModelManifest;
use Lunar\Models\Contracts\Product as ProductContract;
use Lunar\Models\Product as LunarProduct;
use Lunar\Models\ProductOption; use Lunar\Models\ProductOption;
use Lunar\Models\ProductOptionValue; use Lunar\Models\ProductOptionValue;
use Modules\Core\Catalog\Events\ProductDeleted; use Modules\Core\Catalog\Events\ProductDeleted;
use Modules\Core\Catalog\Events\ProductSaved; use Modules\Core\Catalog\Events\ProductSaved;
use Modules\Core\Catalog\Listeners\ReindexProductsRecommendingProduct; use Modules\Core\Catalog\Listeners\ReindexProductsRecommendingProduct;
use Modules\Core\Catalog\Models\Product;
use Modules\Core\Catalog\Observers\ProductOptionReindexObserver; use Modules\Core\Catalog\Observers\ProductOptionReindexObserver;
use Modules\Core\Catalog\OptionTypes\ColorOptionType; use Modules\Core\Catalog\OptionTypes\ColorOptionType;
use Modules\Core\Catalog\Services\ProductOptionTypeManager; use Modules\Core\Catalog\Services\ProductOptionTypeManager;
@@ -40,13 +43,55 @@ class CatalogServiceProvider extends ServiceProvider
ProductOptionValue::saved(fn (ProductOptionValue $value) => $observer->valueSaved($value)); ProductOptionValue::saved(fn (ProductOptionValue $value) => $observer->valueSaved($value));
ProductOptionValue::deleted(fn (ProductOptionValue $value) => $observer->valueDeleted($value)); ProductOptionValue::deleted(fn (ProductOptionValue $value) => $observer->valueDeleted($value));
Product::saved(fn (Product $product) => Event::dispatch(new ProductSaved($product))); // Registered on BOTH classes — Eloquent model events are keyed by
Product::deleted(fn (Product $product) => Event::dispatch(new ProductDeleted($product->id))); // the literal class ::saved()/::deleted() was called on
// (registerModelEvent() uses static::class at registration time),
// not by inheritance, so a listener registered only on one class
// never fires for an instance of the other. Code resolving Product
// through the contract (Filament's own ProductResource, anything
// using app(Contracts\Product::class)) gets the subclass once
// ModelManifest::replace() below takes effect; code that still
// hardcodes `Lunar\Models\Product` directly (e.g. MigrateImport\
// Shopify\ShopifyExportImporter — importing has no reason to need
// the subclass's own custom_fields cast) keeps creating base-class
// instances. Both must dispatch ProductSaved/ProductDeleted, since
// ReindexProductsRecommendingProduct listens to those regardless
// of which path created/updated the product.
$dispatchSaved = fn (LunarProduct $product) => Event::dispatch(new ProductSaved($product));
$dispatchDeleted = fn (LunarProduct $product) => Event::dispatch(new ProductDeleted($product->id));
LunarProduct::saved($dispatchSaved);
LunarProduct::deleted($dispatchDeleted);
Product::saved($dispatchSaved);
Product::deleted($dispatchDeleted);
Event::listen(ProductSaved::class, [ReindexProductsRecommendingProduct::class, 'handleSaved']); Event::listen(ProductSaved::class, [ReindexProductsRecommendingProduct::class, 'handleSaved']);
Event::listen(ProductDeleted::class, [ReindexProductsRecommendingProduct::class, 'handleDeleted']); Event::listen(ProductDeleted::class, [ReindexProductsRecommendingProduct::class, 'handleDeleted']);
$this->app->booted(function () { $this->app->booted(function () {
// Deferred to booted() to run after every provider (Lunar's
// own included) has finished its own boot() — matches
// 3dealer's own AppServiceProvider, which registers Customer
// the same way for the same reason.
//
// The first argument MUST be the CONTRACT
// (Lunar\Models\Contracts\Product), not the concrete
// Lunar\Models\Product — HasModelExtending::modelClass()
// (which every Lunar model's __callStatic()/newModelQuery()
// consults to decide "is there a registered replacement for
// me") looks itself up by
// ModelManifest::guessContractClass(static::class), which
// resolves to the CONTRACT interface, then does
// ModelManifest::get($thatContract) — so the manifest must be
// keyed by the contract, or the lookup simply misses and
// silently falls back to the base class. Caught in practice —
// passing the concrete LunarProduct::class here (mirroring
// Modules\Core\Customer\Providers\CustomerServiceProvider's
// own replace() call, which has this exact same bug) left
// Product::modelClass() resolving to Lunar\Models\Product no
// matter what, until this was corrected.
ModelManifest::replace(ProductContract::class, Product::class);
// A full nightly reindex, on top of the per-event reindexing // A full nightly reindex, on top of the per-event reindexing
// above — catches everything event-driven reindexing // above — catches everything event-driven reindexing
// deliberately doesn't cover: a newly-created product not yet // deliberately doesn't cover: a newly-created product not yet
@@ -58,8 +103,14 @@ class CatalogServiceProvider extends ServiceProvider
// documents, so a deploy that changed ProductIndexer's field // documents, so a deploy that changed ProductIndexer's field
// list self-heals here even if `lunar:meilisearch:setup` // list self-heals here even if `lunar:meilisearch:setup`
// wasn't run manually after that deploy. // wasn't run manually after that deploy.
// References the subclass, not 'Lunar\Models\Product' — Scout's
// own reindex loop (Searchable::makeAllSearchable()) queries
// via whatever class name is passed here, so this determines
// which class's casts (custom_fields included) are actually
// applied to $model in ProductIndexer::toSearchableArray()
// during this nightly full reindex.
$this->app->make(Schedule::class) $this->app->make(Schedule::class)
->command('lunar:search:index', ['Lunar\\Models\\Product', '--refresh']) ->command('lunar:search:index', [Product::class, '--refresh'])
->dailyAt('03:00'); ->dailyAt('03:00');
}); });
} }
+2 -1
View File
@@ -13,6 +13,7 @@ use Modules\Core\Command\InstallLunarCommand;
use Modules\Core\Command\MigrateImportCommand; use Modules\Core\Command\MigrateImportCommand;
use Modules\Core\Command\ProcessErasureRequestsCommand; use Modules\Core\Command\ProcessErasureRequestsCommand;
use Modules\Core\Command\TuneProductSearchCommand; use Modules\Core\Command\TuneProductSearchCommand;
use Modules\Core\Command\WipeCatalogCommand;
class CoreServiceProvider extends ServiceProvider class CoreServiceProvider extends ServiceProvider
{ {
@@ -39,7 +40,7 @@ class CoreServiceProvider extends ServiceProvider
], 'core-assets'); ], 'core-assets');
if ($this->app->runningInConsole()) { if ($this->app->runningInConsole()) {
$this->commands([AnonymizeCommand::class, ExportCommand::class, ExportCleanupCommand::class, ImportCommand::class, MigrateImportCommand::class, TuneProductSearchCommand::class, BackfillMissingSkusCommand::class, ProcessErasureRequestsCommand::class]); $this->commands([AnonymizeCommand::class, ExportCommand::class, ExportCleanupCommand::class, ImportCommand::class, MigrateImportCommand::class, TuneProductSearchCommand::class, BackfillMissingSkusCommand::class, ProcessErasureRequestsCommand::class, WipeCatalogCommand::class]);
//Overriding lunar:install //Overriding lunar:install
$this->app->booted(fn() => $this->commands([InstallLunarCommand::class])); $this->app->booted(fn() => $this->commands([InstallLunarCommand::class]));
+14 -1
View File
@@ -19,7 +19,20 @@ class CustomerServiceProvider extends ServiceProvider
{ {
public function boot(): void public function boot(): void
{ {
ModelManifest::replace(LunarCustomer::class, Customer::class); // Deferred to booted() — LunarServiceProvider (lunarphp/core)
// calls Facades\ModelManifest::register() from its OWN boot(),
// which re-discovers every Lunar\Models\* class and repopulates
// the whole manifest from scratch, silently undoing a replace()
// call made from a boot() that ran earlier in the provider list.
// booted() fires only once every provider's boot() has completed,
// guaranteeing this is the one that actually sticks — same fix,
// same reasoning, as Providers\CatalogServiceProvider's own
// Product replace() call. This one likely only "worked" before
// because 3dealer's own AppServiceProvider independently
// re-registers Customer correctly in its own booted() — a
// consuming app without that redundant registration would have
// silently gotten the base Lunar\Models\Customer back.
$this->app->booted(fn () => ModelManifest::replace(LunarCustomer::class, Customer::class));
Event::listen(UserCreated::class, CreateCustomerForUser::class); Event::listen(UserCreated::class, CreateCustomerForUser::class);
+4
View File
@@ -21,6 +21,7 @@ use Modules\Core\Order\Listeners\CompleteOrderOnPickedUp;
use Modules\Core\Order\Listeners\DecrementStockOnOrderPlaced; use Modules\Core\Order\Listeners\DecrementStockOnOrderPlaced;
use Modules\Core\Order\Listeners\DeriveOrderDeliveredFromShipment; use Modules\Core\Order\Listeners\DeriveOrderDeliveredFromShipment;
use Modules\Core\Order\Listeners\MarkDeliveryFailedOnCarrierCheckpoint; use Modules\Core\Order\Listeners\MarkDeliveryFailedOnCarrierCheckpoint;
use Modules\Core\Order\Listeners\MarkOrderPlacedOnDeferredPayment;
use Modules\Core\Order\Listeners\RecordPaymentTransaction; use Modules\Core\Order\Listeners\RecordPaymentTransaction;
use Modules\Core\Order\Listeners\RecordStatusTransition; use Modules\Core\Order\Listeners\RecordStatusTransition;
use Modules\Core\Order\Models\OrderStatusTransition; use Modules\Core\Order\Models\OrderStatusTransition;
@@ -37,6 +38,7 @@ use Modules\Core\Order\Observers\TransactionObserver;
use Modules\Core\Order\Support\OrderStatus; use Modules\Core\Order\Support\OrderStatus;
use Modules\Core\Payment\Events\PaymentAuthorized; use Modules\Core\Payment\Events\PaymentAuthorized;
use Modules\Core\Payment\Events\PaymentCaptured; use Modules\Core\Payment\Events\PaymentCaptured;
use Modules\Core\Payment\Events\PaymentDeferred;
use Modules\Core\Payment\Events\PaymentRefunded; use Modules\Core\Payment\Events\PaymentRefunded;
use Modules\Core\Payment\Events\PaymentVoided; use Modules\Core\Payment\Events\PaymentVoided;
use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier; use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier;
@@ -74,6 +76,8 @@ class OrderServiceProvider extends ServiceProvider
Event::listen(PaymentRefunded::class, RecordPaymentTransaction::class); Event::listen(PaymentRefunded::class, RecordPaymentTransaction::class);
Event::listen(PaymentRefunded::class, ApplyResolvedPaymentStatus::class); Event::listen(PaymentRefunded::class, ApplyResolvedPaymentStatus::class);
Event::listen(PaymentDeferred::class, MarkOrderPlacedOnDeferredPayment::class);
Event::listen(OrderPlaced::class, DecrementStockOnOrderPlaced::class); Event::listen(OrderPlaced::class, DecrementStockOnOrderPlaced::class);
Event::listen(OrderStatusChanged::class, [RecordStatusTransition::class, 'handleStatusChanged']); Event::listen(OrderStatusChanged::class, [RecordStatusTransition::class, 'handleStatusChanged']);
@@ -2,9 +2,31 @@
namespace Modules\Core\Review\Filament\Extensions; namespace Modules\Core\Review\Filament\Extensions;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Lunar\Admin\Support\Extending\ResourceExtension; use Lunar\Admin\Support\Extending\ResourceExtension;
use Modules\Core\Review\Filament\Pages\ManageProductReviews; use Modules\Core\Review\Filament\Pages\ManageProductReviews;
/**
* CorePlugin allows exactly one extension class per Lunar resource — this
* one already owned ProductResource (adding the Reviews sub-page) before
* Catalog needed its own product-form addition, so extendForm() lives
* here too rather than competing for the same resource slot.
*
* extendForm() adds a "Custom Fields" repeater authoring Product::
* $custom_fields (see the migration adding that column, and Modules\Core\
* Catalog\Models\Product's own docblock on why this needed a first-party
* Product subclass rather than being addable to the base Lunar model) —
* per-product, customer-authored input (a reference photo upload, an
* optional engraving textarea) rendered on the storefront product page,
* NOT a Lunar ProductOption: an option's values are a fixed, admin-
* authored list that define variants, which doesn't fit "the customer
* uploads their own unique photo."
*/
class ProductResourceExtension extends ResourceExtension class ProductResourceExtension extends ResourceExtension
{ {
public function extendPages(array $pages): array public function extendPages(array $pages): array
@@ -18,4 +40,56 @@ class ProductResourceExtension extends ResourceExtension
{ {
return [...$pages, ManageProductReviews::class]; return [...$pages, ManageProductReviews::class];
} }
public function extendForm(Schema $schema): Schema
{
return $schema->components([
...$schema->getComponents(),
$this->customFieldsSection(),
]);
}
private function customFieldsSection(): Section
{
return Section::make('Custom Fields')
->description('Extra input the shopper fills in on this product\'s page before adding it to their cart — a reference photo, personalization text, etc.')
->collapsible()
->collapsed(fn ($record) => blank($record?->custom_fields))
->schema([
Repeater::make('custom_fields')
->hiddenLabel()
->schema([
TextInput::make('label')
->label('Label')
->helperText('Shown to the shopper above the field.')
->required(),
Select::make('type')
->label('Field type')
->options([
'text' => 'Short text',
'textarea' => 'Long text',
'file' => 'File upload',
])
->default('text')
->native(false)
->live()
->required(),
TextInput::make('key')
->label('Key')
->helperText('Machine-facing identifier — stored on the order/cart line, used to look up this answer elsewhere. Cannot be changed once orders reference it.')
->required()
->alphaDash()
->maxLength(64),
Toggle::make('required')
->label('Required')
->helperText('Shopper cannot add this product to their cart without answering.')
->default(false),
])
->columns(2)
->addActionLabel('Add a custom field')
->reorderable()
->collapsible()
->itemLabel(fn (array $state): ?string => $state['label'] ?? null),
]);
}
} }
@@ -61,6 +61,20 @@ class BoxNowClient
return $response->json() ?? []; return $response->json() ?? [];
} }
/**
* List available APM (locker) destinations — the data behind Box Now's
* own Destination Map widget, which only works against their
* Production environment (not Stage/sandbox). Used to build a plain
* locker picker on the storefront checkout instead, working against
* whichever environment is configured.
*
* @return array<int, array<string, mixed>>
*/
public function destinations(array $query = []): array
{
return $this->locationRequest('/destinations', $query)['data'] ?? [];
}
/** /**
* Fetch raw bytes (e.g. a PDF label) rather than JSON. * Fetch raw bytes (e.g. a PDF label) rather than JSON.
*/ */
@@ -67,7 +67,7 @@ class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsT
'locationId' => config('boxnow.origin_location_id'), 'locationId' => config('boxnow.origin_location_id'),
], ],
'destination' => [ 'destination' => [
'contactNumber' => $address->contact_phone, 'contactNumber' => $this->internationalPhone($address->contact_phone),
'contactEmail' => $address->contact_email, 'contactEmail' => $address->contact_email,
'contactName' => trim("{$address->first_name} {$address->last_name}"), 'contactName' => trim("{$address->first_name} {$address->last_name}"),
'locationId' => $destinationLocationId, 'locationId' => $destinationLocationId,
@@ -105,6 +105,37 @@ class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsT
return $shipments->first(); return $shipments->first();
} }
/**
* Box Now rejects any contactNumber not in full international format
* (error P405 — confirmed in practice: a plain Greek mobile like
* "6955994563" 400s with {"code":"P405"}). Checkout collects phone
* numbers in local format, with no international-format enforcement of
* its own — this store is Greece-only (see CheckoutController::
* STORE_COUNTRY_ISO3), so a bare local number is assumed Greek and
* prefixed accordingly, same convention ACS's own sender config already
* uses (config('boxnow.sender.phone') is documented as "+30..." there
* too). A number already carrying a country code (leading "+" or "00")
* is passed through unchanged.
*/
private function internationalPhone(?string $phone): ?string
{
if ($phone === null) {
return null;
}
$digitsOnly = preg_replace('/[^\d+]/', '', $phone);
if (str_starts_with($digitsOnly, '+')) {
return $digitsOnly;
}
if (str_starts_with($digitsOnly, '00')) {
return '+'.substr($digitsOnly, 2);
}
return '+30'.ltrim($digitsOnly, '0');
}
public function printLabel(Shipment $shipment): string public function printLabel(Shipment $shipment): string
{ {
$bytes = $this->client->requestRaw("/parcels/{$shipment->tracking_reference}/label.pdf"); $bytes = $this->client->requestRaw("/parcels/{$shipment->tracking_reference}/label.pdf");
@@ -5,6 +5,8 @@ namespace Modules\Core\Shipping\Extensions;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Infolists\Components\RepeatableEntry; use Filament\Infolists\Components\RepeatableEntry;
use Filament\Infolists\Components\TextEntry; use Filament\Infolists\Components\TextEntry;
use Illuminate\Support\Collection;
use Modules\Core\Shipping\Models\ShipmentInfo;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Schemas\Components\Section; use Filament\Schemas\Components\Section;
use Illuminate\Support\Facades\URL; use Illuminate\Support\Facades\URL;
@@ -112,10 +114,34 @@ class OrderShipmentsExtension extends ViewPageExtension
->action(fn (Shipment $record) => $this->cancel($record)) ->action(fn (Shipment $record) => $this->cancel($record))
->visible(fn (Shipment $record) => ! $record->cancelled_at), ->visible(fn (Shipment $record) => ! $record->cancelled_at),
]), ]),
RepeatableEntry::make('shipmentInfo')
->label('Tracking history')
->state(fn (Shipment $record) => $this->orderedCheckpoints($record))
->hidden(fn (Shipment $record) => $record->shipmentInfo->isEmpty())
->schema([
TextEntry::make('status')
->label(fn (ShipmentInfo $record) => $record->occurred_at->format('Y-m-d H:i'))
->inlineLabel()
->state(fn (ShipmentInfo $record) => (string) str($record->status->value)->replace('_', ' ')->title())
->helperText(fn (ShipmentInfo $record) => $record->location),
]),
]), ]),
]); ]);
} }
/**
* Oldest first — a delivery journey (Collected → In Transit →
* Delivered) reads naturally top-to-bottom in that order, unlike
* statusLabel()/statusColor() above which only ever need the single
* latest checkpoint and so use latestShipmentInfo() directly instead.
*
* @return Collection<int, ShipmentInfo>
*/
private function orderedCheckpoints(Shipment $record): Collection
{
return $record->shipmentInfo->sortBy('occurred_at')->values();
}
private function carrierLabel(Shipment $record): string private function carrierLabel(Shipment $record): string
{ {
return match ($record->carrier) { return match ($record->carrier) {
+23 -12
View File
@@ -56,10 +56,11 @@ use Modules\Core\Shipping\Support\WeightCalculator;
* each with its own S/M/L size) instead — see * each with its own S/M/L size) instead — see
* Modules\Core\Shipping\Carriers\BoxNow\BoxNowFulfillmentService for how * Modules\Core\Shipping\Carriers\BoxNow\BoxNowFulfillmentService for how
* multiple boxes become multiple Shipment rows from one delivery request. * multiple boxes become multiple Shipment rows from one delivery request.
* Box Now's locker is locked to read-only once the shopper's own checkout * Box Now's locker field defaults from the shopper's own checkout
* selection ($order->shippingAddress->meta['box_now_locker']) is present * selection ($order->shippingAddress->meta['box_now_locker']) when present,
* — staff can only fill it in manually for the (current, checkout-UI-less) * but stays editable — staff can override to a different locker (e.g. the
* case where nothing set it yet. * customer's choice turns out to be unavailable) or fill it in manually for
* an order placed before the checkout locker picker existed.
* *
* "Mark Paid" is a third, separate header action — Order::paid is * "Mark Paid" is a third, separate header action — Order::paid is
* independent of `status` (see OrderStatusFlow's own docblock), so it * independent of `status` (see OrderStatusFlow's own docblock), so it
@@ -124,16 +125,9 @@ class OrderViewExtension extends ViewPageExtension
TextInput::make('destination_location_id') TextInput::make('destination_location_id')
->label('Box Now locker ID') ->label('Box Now locker ID')
->default($lockerId) ->default($lockerId)
// Locked once the shopper's own checkout selection is
// known — staff should not be able to redirect a
// parcel to a different locker than the one the
// customer picked. Only editable for the (current,
// checkout-UI-less) case where nothing set it yet.
->disabled(filled($lockerId))
->dehydrated()
->required() ->required()
->helperText($lockerId ->helperText($lockerId
? 'Set by the customer at checkout.' ? 'Set by the customer at checkout — override if the parcel needs to go to a different locker.'
: 'No locker was selected at checkout — enter it manually.'), : 'No locker was selected at checkout — enter it manually.'),
Repeater::make('boxes') Repeater::make('boxes')
->label('Boxes') ->label('Boxes')
@@ -152,12 +146,29 @@ class OrderViewExtension extends ViewPageExtension
]; ];
}) })
->action(function (Order $record, array $data, Action $action) { ->action(function (Order $record, array $data, Action $action) {
// Derived from the order itself, never from staff input —
// whether a shipment collects cash on delivery is a fact
// about the order (which PaymentMethod it was placed
// against), not a choice to make again at dispatch time.
// Previously this was never set at all (defaulted to
// ShipmentRequest::$paymentMode's own null), which silently
// made AcsFulfillmentService::createShipment()'s
// `$request->paymentMode === 'cod'` branch (sends
// Cod_Ammount/Cod_Payment_Way to ACS) permanently
// unreachable, and BoxNowFulfillmentService::createShipment()
// always ship 'prepaid' with amountToBeCollected '0.00' —
// a real COD order would arrive with the carrier believing
// full payment was already settled, and never collect it.
$isCod = app(OrderStatusFlow::class)->isCod($record);
$result = $this->service()->createShipmentAndDispatch( $result = $this->service()->createShipmentAndDispatch(
$record, $record,
new ShipmentRequest( new ShipmentRequest(
weight: filled($data['weight'] ?? null) ? (float) $data['weight'] : null, weight: filled($data['weight'] ?? null) ? (float) $data['weight'] : null,
packageCount: (int) ($data['package_count'] ?? 1), packageCount: (int) ($data['package_count'] ?? 1),
destinationLocationId: $data['destination_location_id'] ?? null, destinationLocationId: $data['destination_location_id'] ?? null,
paymentMode: $isCod ? 'cod' : 'prepaid',
amountToCollect: $isCod ? $record->total->decimal : null,
boxes: collect($data['boxes'] ?? [])->pluck('size')->all(), boxes: collect($data['boxes'] ?? [])->pluck('size')->all(),
), ),
); );
@@ -12,11 +12,15 @@ use Lunar\Shipping\Filament\Resources\ShippingMethodResource;
/** /**
* ListShippingMethod::getDefaultHeaderActions() builds its CreateAction's * ListShippingMethod::getDefaultHeaderActions() builds its CreateAction's
* form inline (calling ShippingMethodResource::getDriverFormComponent() * form inline (calling ShippingMethodResource::getDriverFormComponent()/
* directly, a hardcoded 2-option Select) rather than through the resource's * getNameFormComponent() directly, hardcoded vendor components) rather
* own extendForm() pipeline, so ShippingMethodResourceExtension's driver * than through the resource's own extendForm() pipeline, so neither
* fix never reaches it. Re-declares the same create-action form with a * ShippingMethodResourceExtension's driver Select nor its translated
* dynamic driver Select instead. * `name` field ever reached this action — `name`'s plain-string TextInput
* in particular used to insert a raw string into the now-JSON `name`
* column, crashing with a Postgres "invalid input syntax for type json"
* error on every create. Re-declares the same create-action form with
* both fixes reapplied instead.
*/ */
class ShippingMethodListExtension extends BaseExtension class ShippingMethodListExtension extends BaseExtension
{ {
@@ -25,7 +29,7 @@ class ShippingMethodListExtension extends BaseExtension
foreach ($actions as $action) { foreach ($actions as $action) {
if ($action instanceof CreateAction) { if ($action instanceof CreateAction) {
$action->schema([ $action->schema([
ShippingMethodResource::getNameFormComponent(), ShippingMethodResourceExtension::translatedNameField(),
Group::make([ Group::make([
ShippingMethodResource::getCodeFormComponent(), ShippingMethodResource::getCodeFormComponent(),
$this->driverSelect(), $this->driverSelect(),
@@ -43,7 +43,7 @@ class ShippingMethodResourceExtension extends ResourceExtension
{ {
return array_map(function (Component $component) { return array_map(function (Component $component) {
if (method_exists($component, 'getName') && $component->getName() === 'name') { if (method_exists($component, 'getName') && $component->getName() === 'name') {
return $this->translatedNameField(); return self::translatedNameField();
} }
if (in_array(HasChildComponents::class, class_uses_recursive($component), true)) { if (in_array(HasChildComponents::class, class_uses_recursive($component), true)) {
@@ -68,14 +68,28 @@ class ShippingMethodResourceExtension extends ResourceExtension
* the model attribute is a string on the way in and out, only ever * the model attribute is a string on the way in and out, only ever
* an array while Filament's schema state holds it. * an array while Filament's schema state holds it.
*/ */
private function translatedNameField(): TranslatedText /**
* Also called directly by Modules\Core\Shipping\Extensions\
* ShippingMethodListExtension — the create action's form is built
* inline by the vendor's ListShippingMethod page rather than through
* this class's own extendForm() pipeline, so it needs the same
* translated field wired in separately.
*/
public static function translatedNameField(): TranslatedText
{ {
$field = TranslatedText::make('name') $field = TranslatedText::make('name')
->label('Name') ->label('Name')
->required() ->required()
->afterStateHydrated(function (TranslatedText $component, $state) { ->afterStateHydrated(function (TranslatedText $component, $state) {
$decoded = json_decode((string) $state, true); // On create there is no record yet, so Filament hydrates
$component->state(is_array($decoded) ? $decoded : []); // this from the field's own default/current state — already
// an array (or null), never the raw JSON string edit gets
// from the model attribute. Only decode when it's a string.
if (is_string($state)) {
$state = json_decode($state, true);
}
$component->state(is_array($state) ? $state : []);
}) })
->dehydrateStateUsing(fn ($state) => json_encode(is_array($state) ? $state : [])); ->dehydrateStateUsing(fn ($state) => json_encode(is_array($state) ? $state : []));
@@ -4,6 +4,7 @@ namespace Modules\Core\Shipping\Filament\Pages;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
use Filament\Schemas\Components\Utilities\Get; use Filament\Schemas\Components\Utilities\Get;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
@@ -11,6 +12,7 @@ use Illuminate\Database\Eloquent\Model;
use Lunar\Shipping\Filament\Resources\ShippingZoneResource\Pages\ManageShippingRates as BaseManageShippingRates; use Lunar\Shipping\Filament\Resources\ShippingZoneResource\Pages\ManageShippingRates as BaseManageShippingRates;
use Lunar\Shipping\Models\ShippingMethod; use Lunar\Shipping\Models\ShippingMethod;
use Lunar\Shipping\Models\ShippingRate; use Lunar\Shipping\Models\ShippingRate;
use Modules\Core\Shipping\Support\ShippingMethodName;
/** /**
* Bound in place of the vendor ManageShippingRates page via the container * Bound in place of the vendor ManageShippingRates page via the container
@@ -33,6 +35,16 @@ use Lunar\Shipping\Models\ShippingRate;
* null-guard, which crashes on any rate with no basePrices row — routine * null-guard, which crashes on any rate with no basePrices row — routine
* for a live rate that has never had a fallback price configured. Same * for a live rate that has never had a fallback price configured. Same
* logic, just null-safe. * logic, just null-safe.
*
* Also replaces the vendor's `shipping_method_id` Select, which uses
* ->relationship(titleAttribute: 'name') — Filament builds that option
* list with `orderBy('name')`/`pluck('name', ...)` against the DB, but
* ShippingMethod.name is now a locale-keyed JSON column (see database/
* migrations/..._make_shipping_methods_name_translatable.php) that
* Postgres has no default ordering operator for, crashing with
* "could not identify an ordering operator for type json" the moment
* this page loads. Resolved app-side instead via ShippingMethodName,
* same as every other read site for this column.
*/ */
class ManageShippingRates extends BaseManageShippingRates class ManageShippingRates extends BaseManageShippingRates
{ {
@@ -41,10 +53,30 @@ class ManageShippingRates extends BaseManageShippingRates
$schema = parent::form($schema); $schema = parent::form($schema);
return $schema->components( return $schema->components(
$this->labelPriceFieldsAsFallbackWhenLive($schema->getComponents()) $this->labelPriceFieldsAsFallbackWhenLive(
$this->replaceShippingMethodField($schema->getComponents())
)
); );
} }
private function replaceShippingMethodField(array $components): array
{
return array_map(function ($component) {
if (method_exists($component, 'getName') && $component->getName() === 'shipping_method_id') {
return Select::make('shipping_method_id')
->label($component->getLabel())
->required()
->live()
->options(fn () => ShippingMethod::all()
->mapWithKeys(fn (ShippingMethod $method) => [$method->id => ShippingMethodName::resolve($method)]))
->searchable()
->columnSpan(2);
}
return $component;
}, $components);
}
private function labelPriceFieldsAsFallbackWhenLive(array $components): array private function labelPriceFieldsAsFallbackWhenLive(array $components): array
{ {
$isLive = fn (Get $get) => static::methodChargeBy($get('shipping_method_id')) === 'live'; $isLive = fn (Get $get) => static::methodChargeBy($get('shipping_method_id')) === 'live';
@@ -86,6 +118,14 @@ class ManageShippingRates extends BaseManageShippingRates
return $table->columns( return $table->columns(
array_map(function ($column) { array_map(function ($column) {
if (method_exists($column, 'getName') && $column->getName() === 'shippingMethod.name') {
return TextColumn::make('shippingMethod.name')
->label(__('lunarpanel.shipping::relationmanagers.shipping_rates.table.shipping_method.label'))
->state(fn (ShippingRate $record) => $record->shippingMethod
? ShippingMethodName::resolve($record->shippingMethod)
: null);
}
if (method_exists($column, 'getName') && $column->getName() === 'basePrices.0') { if (method_exists($column, 'getName') && $column->getName() === 'basePrices.0') {
return TextColumn::make('basePrices.0') return TextColumn::make('basePrices.0')
->label(__('lunarpanel.shipping::relationmanagers.shipping_rates.table.price.label')) ->label(__('lunarpanel.shipping::relationmanagers.shipping_rates.table.price.label'))
+12 -1
View File
@@ -14,12 +14,19 @@ use Modules\Core\Shipping\Enums\TrackingStatus;
use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier; use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier;
use Modules\Core\Shipping\Models\Shipment; use Modules\Core\Shipping\Models\Shipment;
use Modules\Core\Shipping\Models\ShipmentInfo; use Modules\Core\Shipping\Models\ShipmentInfo;
use Throwable;
/** /**
* Carrier-agnostic: polls every Shipment not yet in a terminal state, * Carrier-agnostic: polls every Shipment not yet in a terminal state,
* skipping carriers whose fulfillment service doesn't implement * skipping carriers whose fulfillment service doesn't implement
* SupportsTracking. New checkpoints are recorded in shipment_info and * SupportsTracking. New checkpoints are recorded in shipment_info and
* dispatch ShipmentStatusUpdatedByCarrier — one event per new checkpoint. * dispatch ShipmentStatusUpdatedByCarrier — one event per new checkpoint.
*
* Each shipment's trackShipment() call is individually try/caught in
* pollCarrierShipments() — one shipment's tracking lookup failing (a
* carrier 500, a malformed parcel response) must not stop the rest of that
* carrier's shipments in the same batch from being polled. The failure is
* reported and the loop continues.
*/ */
class PollShipmentTrackingJob implements ShouldQueue class PollShipmentTrackingJob implements ShouldQueue
{ {
@@ -68,7 +75,11 @@ class PollShipmentTrackingJob implements ShouldQueue
} }
foreach ($shipments as $shipment) { foreach ($shipments as $shipment) {
$this->recordNewCheckpoints($shipment, $service->trackShipment($shipment)); try {
$this->recordNewCheckpoints($shipment, $service->trackShipment($shipment));
} catch (Throwable $e) {
report($e);
}
} }
} }