Compare commits

...
14 Commits
Author SHA1 Message Date
arvanitakis 7b63f43695 Merge branch 'Shipping-Carriers' into Checkout 2026-08-28 13:17:58 +03:00
arvanitakis 66068ef68a Bump version to 0.8.0 2026-08-28 13:15:40 +03:00
arvanitakis f6ef0761d6 Feature: Creating Cart Service, Cart Events, Save For Later functionality, Adding Filament Views for viewing abandoned carts 2026-08-28 13:14:47 +03:00
arvanitakis a8ddbb8056 Feature: Creating Cart Views, rules for abandonment 2026-08-28 00:27:46 +03:00
arvanitakis 9f30a7324e Feature: Shipment Updates to handle COD 2026-07-19 18:27:24 +03:00
arvanitakis 435a4dd290 Feat: Adding Shippment Tracking 2026-07-19 17:41:09 +03:00
arvanitakis d34e450526 Fix: Move carrier live-pricing choice onto ShippingMethod.charge_by
Reverts the earlier per-rate pricing_mode column in favor of extending Lunar's existing charge_by field (cart_total/weight) with a third "live" option, gated by a SupportsLivePricing capability check on the driver. Adds a shared ResolvesFixedPricing trait so any carrier driver can fall back to Lunar's normal price-break resolution, matching the vendor ShipBy driver's own charge_by handling instead of introducing a separate mechanism. Also fixes an incorrect Get() path in the admin form that silently hid the new "live" option.
2026-07-19 02:24:10 +03:00
arvanitakis 4acabe4185 Feature: Wire up carrier-agnostic shipping admin UI
Registers ShippingServiceProvider (carrier config, driver/fulfillment bindings, Rates page override) and wires the shipping admin surface into CorePlugin: dynamic carrier dropdown on Shipping Method create/edit, a "Create Shipment" order action resolved generically by carrier, and a Pickup Manifests page for carriers that support manifest batching.
2026-07-19 00:52:01 +03:00
arvanitakis 37c2e1194c Feature: Add Box Now locker delivery integration
Box Now rate driver (fixed pricing only — no live pricing API) and fulfillment service (delivery request creation, label printing, cancellation). Unlike ACS, Box Now books courier pickup automatically on delivery request creation, so no manifest/pickup-list step is implemented. Storefront locker selection is not yet built; createShipment() expects the chosen locker's locationId to be supplied by the caller.
2026-07-19 00:51:23 +03:00
arvanitakis 89255687a1 Feature: Add ACS courier integration
ACS rate driver (live price quotes via ACS_Price_Calculation, cached postcode-to-station lookups) and fulfillment service (voucher creation, label printing, end-of-day pickup manifest). Adds a per-rate pricing_mode column so admins can choose live API pricing vs. a fixed price on ACS-driven shipping rates, surfaced via a custom Rates page.
2026-07-19 00:50:51 +03:00
arvanitakis 3599329b57 Feature: Add ManifestResult DTO for carrier manifest batching
Completes the carrier-agnostic shipping abstraction (CarrierFulfillmentInterface, SupportsManifestBatching, Shipment model) with the result type SupportsManifestBatching::issueManifest() returns.
2026-07-19 00:50:15 +03:00
arvanitakis 9618d19cfa Feature: Creating Migration For Adding Pricing Mode to Shipping Rates 2026-07-19 00:42:13 +03:00
arvanitakis 08135c4c8f Feature: Creating Shipping Carrier Contracts 2026-07-16 17:14:41 +03:00
arvanitakis e18b2fa44c Feature: Creating Shipment migration and model 2026-07-16 17:14:23 +03:00
61 changed files with 3423 additions and 4 deletions
+5
View File
@@ -4,6 +4,11 @@ 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/).
## [0.8.0] - 2026-08-27
### Added
- `Modules\Core\Cart\Filament\Resources\CartResource` gives staff read-only visibility into carts in the Filament admin panel — Lunar ships no cart admin view at all. Scoped to carts with a known `user_id`/`customer_id` (an anonymous guest cart carries no identity staff could act on); list table shows customer/user, line/item counts (via Filament's built-in `->counts()`/`->sum()`, no per-row queries), currency, and last activity. List page has only two tabs, **Abandoned** (default active) and **Completed** — no "All" tab, so the list never runs an unfiltered fetch over the whole table. They key off whether the cart has a **placed** order (`orders.placed_at IS NOT NULL`), not `Cart::completed_at` — that column is declared/cast on the model but never actually written anywhere in Lunar core, so it's not a real signal; "Abandoned" mirrors Lunar's own `Cart::scopeActive()`. `getNavigationBadge()` shows the abandoned-cart count in the sidebar via a single `COUNT(*)` query, no rows loaded. View page runs `$cart->calculate()` once so line/cart totals (plain public properties Lunar never persists) are populated, without paying that cost per row in the list. Documented in `docs/cart.md`.
## [0.7.0] - 2026-08-27
### Added
+4 -2
View File
@@ -2,7 +2,7 @@
"name": "boboko/core",
"description": "Core module — authentication and shared panel behaviour",
"type": "library",
"version": "0.7.0",
"version": "0.8.0",
"autoload": {
"psr-4": {
"Modules\\Core\\": "src/"
@@ -37,7 +37,9 @@
"Modules\\Core\\Providers\\CustomerServiceProvider",
"Modules\\Core\\Providers\\LocalizationServiceProvider",
"Modules\\Core\\Providers\\CatalogServiceProvider",
"Modules\\Core\\Providers\\ReviewServiceProvider"
"Modules\\Core\\Providers\\CartServiceProvider",
"Modules\\Core\\Providers\\ReviewServiceProvider",
"Modules\\Core\\Providers\\ShippingServiceProvider"
]
}
},
+16
View File
@@ -16,4 +16,20 @@ return [
'auto_create_customer_for_user' => true,
/*
|--------------------------------------------------------------------------
| Cart Abandonment Threshold
|--------------------------------------------------------------------------
|
| How long a cart (that hasn't converted to a placed order) can go without
| activity before Modules\Core\Cart\Filament\Resources\CartResource treats
| it as "Abandoned" rather than "Ongoing". Anything DateInterval::createFromDateString()
| accepts works, e.g. '1 hour', '30 minutes', '2 days'.
|
*/
'cart' => [
'abandoned_after' => '1 hour',
],
];
+50
View File
@@ -0,0 +1,50 @@
<?php
/*
|--------------------------------------------------------------------------
| ACS Courier credentials
|--------------------------------------------------------------------------
|
| ACS requires two credential mechanisms simultaneously: an AcsApiKey
| HTTP header (gates the REST gateway itself) and four account fields
| (Company_ID/Company_Password/User_ID/User_Password) sent in every
| request body. Both are supplied by ACS when your account is set up.
|
| Set these via environment variables — never commit real values.
|
| ACS_BASE_URL Root REST endpoint (unversioned, single URL for
| every ACSAlias call).
| ACS_API_KEY The AcsApiKey header value.
| ACS_COMPANY_ID Company_ID body field.
| ACS_COMPANY_PASSWORD Company_Password body field.
| ACS_USER_ID User_ID body field.
| ACS_USER_PASSWORD User_Password body field.
| ACS_BILLING_CODE Your ACS credit/billing code, used for price
| calculation and voucher creation.
| ACS_SENDER_* Static sender details reused on every voucher.
|
*/
return [
'base_url' => env('ACS_BASE_URL', 'https://webservices.acscourier.net/ACSRestServices/api/ACSAutoRest'),
'api_key' => env('ACS_API_KEY'),
'company_id' => env('ACS_COMPANY_ID'),
'company_password' => env('ACS_COMPANY_PASSWORD'),
'user_id' => env('ACS_USER_ID'),
'user_password' => env('ACS_USER_PASSWORD'),
'billing_code' => env('ACS_BILLING_CODE'),
'sender' => [
'name' => env('ACS_SENDER_NAME'),
'address' => env('ACS_SENDER_ADDRESS'),
'zip_code' => env('ACS_SENDER_ZIP'),
'phone' => env('ACS_SENDER_PHONE'),
],
'timeout' => env('ACS_HTTP_TIMEOUT', 10),
];
+47
View File
@@ -0,0 +1,47 @@
<?php
/*
|--------------------------------------------------------------------------
| Box Now credentials
|--------------------------------------------------------------------------
|
| Box Now uses OAuth2 client-credentials: exchange BOXNOW_CLIENT_ID /
| BOXNOW_CLIENT_SECRET for a Bearer access token (POST /auth-sessions,
| ~1hr expiry), then attach it as an Authorization header on every call.
| Unlike ACS, there is no separate per-request credential body — the
| token alone authorizes all calls once obtained.
|
| Set these via environment variables — never commit real values.
|
| BOXNOW_BASE_URL Root REST endpoint for delivery-requests/parcels.
| BOXNOW_LOCATION_API_URL Separate, faster endpoint for origins/destinations
| lookups (Box Now recommends this over the main
| base URL for those two calls specifically).
| BOXNOW_CLIENT_ID OAuth2 client id.
| BOXNOW_CLIENT_SECRET OAuth2 client secret.
| BOXNOW_ORIGIN_LOCATION_ID Your warehouse's Box Now locationId, used as
| the pickup origin on every delivery request.
| BOXNOW_SENDER_* Static sender contact details reused on every
| delivery request.
|
*/
return [
'base_url' => env('BOXNOW_BASE_URL', 'https://api-production.boxnow.gr/api/v1'),
'location_api_url' => env('BOXNOW_LOCATION_API_URL', 'https://locationapi-production.boxnow.gr/api/v1'),
'client_id' => env('BOXNOW_CLIENT_ID'),
'client_secret' => env('BOXNOW_CLIENT_SECRET'),
'origin_location_id' => env('BOXNOW_ORIGIN_LOCATION_ID'),
'sender' => [
'name' => env('BOXNOW_SENDER_NAME'),
'email' => env('BOXNOW_SENDER_EMAIL'),
'phone' => env('BOXNOW_SENDER_PHONE'),
],
'timeout' => env('BOXNOW_HTTP_TIMEOUT', 10),
];
@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('shipments', function (Blueprint $table) {
$table->id();
$table->foreignId('order_id')->constrained(config('lunar.database.table_prefix').'orders');
$table->string('carrier');
$table->string('tracking_reference')->unique();
$table->string('parent_reference')->nullable();
$table->timestamp('label_printed_at')->nullable();
$table->string('manifest_reference')->nullable();
$table->timestamp('cancelled_at')->nullable();
$table->json('meta')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('shipments');
}
};
@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('shipment_info', function (Blueprint $table) {
$table->id();
$table->foreignId('shipment_id')->constrained('shipments')->cascadeOnDelete();
$table->string('status');
$table->string('carrier_status')->nullable();
$table->text('message')->nullable();
$table->string('location')->nullable();
$table->timestamp('occurred_at');
$table->json('meta')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('shipment_info');
}
};
+272
View File
@@ -0,0 +1,272 @@
# Cart Admin Visibility
`Modules\Core\Cart\Filament\Resources\CartResource` gives staff read-only visibility into
customer/user carts in the Filament admin panel. Lunar itself ships no cart admin view at
all — no Filament resource for `Cart`/`CartLine` exists anywhere in `lunarphp/lunar` or
`lunarphp/core` — this is a from-scratch addition, not an extension of something Lunar
half-built. See `docs/lunar.md`'s "Cart and Checkout" section for the underlying Lunar cart
mechanics this resource reads from.
---
## Scope: only carts with a known customer or user
`CartResource::getEloquentQuery()` filters to `Cart::whereNotNull('user_id')->orWhereNotNull('customer_id')`
— an anonymous guest's session cart is excluded entirely.
This was a deliberate call, not an oversight: an anonymous cart carries no identity a staff
member could act on — no name, no email, nothing to follow up with — so listing every guest
session cart would be noise, not a real admin capability. This does **not** mirror Shopify's
admin (Shopify has no "all carts" view at all — only "Abandoned checkouts," gated on a
shopper reaching checkout and entering contact info, a later/narrower stage than Lunar's
`Cart`). Lunar's own `Cart` model already gets `user_id`/`customer_id` set the moment a
shopper is authenticated (via `Lunar\Listeners\CartSessionAuthListener` on login), with no
checkout step required — so scoping to "identifiable" here is broader than Shopify's
equivalent, not a copy of it.
---
## Four states, not two — and not `Cart::completed_at`
`Lunar\Models\Cart::completed_at` is declared and cast (`'completed_at' => 'datetime'`) but
**never actually written anywhere in Lunar core** — grep `vendor/lunarphp/core/src` for it;
the only hits are the property declaration and the cast. It is not a real signal. `Cart` has
no `status` column at all — every state below is derived from relations/timestamps, not a
single field.
`Cart::scopeActive()` (Lunar's own "not yet converted to an order" scope) actually mixes two
distinct states together: no order ever started, vs. a draft order exists
(`placed_at IS NULL`) but was never placed — checkout was started, not finished. Those are
different purchase-intent signals (see "Abandoned Cart vs Abandoned Checkout" below) and
different reachability (checkout usually captures an email even for a guest), so
`ListCarts::getTabs()` splits them into four tabs instead of `scopeActive()`'s two-state
split:
- **Ongoing** — `scopeActive()` and recent `updated_at` (within `abandonedCutoff()`). Default
active tab on page load.
- **Abandoned Cart** — `whereDoesntHave('orders')` and stale `updated_at`.
- **Abandoned Checkout** — has an order with `placed_at IS NULL`, and stale `updated_at`.
- **Completed** — has an order with `placed_at IS NOT NULL`.
```php
// Ongoing
$query->active()->where('updated_at', '>', CartResource::abandonedCutoff());
// Abandoned Cart
$query->whereDoesntHave('orders')->where('updated_at', '<=', CartResource::abandonedCutoff());
// Abandoned Checkout
$query->whereHas('orders', fn ($q) => $q->whereNull('placed_at'))
->where('updated_at', '<=', CartResource::abandonedCutoff());
// Completed
$query->whereHas('orders', fn ($q) => $q->whereNotNull('placed_at'));
```
There is deliberately **no "All" tab.** Every row shown is always scoped to one of the four
states above — the list never runs an unfiltered `Cart::query()->get()` over the whole
(potentially large) table.
### Abandoned Cart vs Abandoned Checkout — why they're not one bucket
Different purchase intent, different reachability, and different recovery strategy — see
`docs/recovery-strategies.md` for the full marketing-strategy discussion. In short:
- **Abandoned Cart** (no order started) is a weak intent signal — often window-shopping, not
a near-purchase. Frequently unreachable (no email/identity at all for a true guest).
Recovery leans on on-site retargeting and ad remarketing rather than email.
- **Abandoned Checkout** (draft order, never placed) is a strong intent signal — the shopper
committed to buying and something blocked completion. Checkout typically captures contact
info even for a guest, so this state is usually reachable. This is the state the
researched 1h/24h/72h recovery-email cadence targets specifically.
`Modules\Core\Cart\Events\CartAbandoned` and `Modules\Core\Checkout\Events\CheckoutAbandoned`
mirror this same split (see "Events" below) rather than one combined event.
---
## Why this scales fine at a large cart count
Two things keep this cheap regardless of how many carts exist (10,000+):
- **The list is always paginated.** Filament applies `LIMIT`/`OFFSET` to whichever tab's
query is active — a page only ever fetches one page's worth of rows, never the whole
table, "All" tab or not (and there is no "All" tab — see above).
- **No per-row queries.** `lines_count`/`lines_sum_quantity` use Filament's built-in
`->counts('lines')`/`->sum('lines', 'quantity')`, which fold into the same query as the
rest of the list (one `LEFT JOIN`-based aggregate, not N separate lookups). There's no
per-record `getStateUsing()` closure anywhere in this table doing its own query — that's
the pattern to avoid if a future column needs derived data (see `Modules\Core\Catalog\
Services\ProductIndexer` for the general "compute once at index time / one aggregate
query, never per-row" principle this project follows elsewhere).
The one thing that **does** scan more rows as the cart count grows is
`CartResource::getNavigationBadge()` (see below) — but it's a `COUNT(*)`, not a fetch, and
runs once per admin page load, not once per cart row.
---
## Navigation badge — abandoned cart count
```php
public static function getNavigationBadge(): ?string
{
return (string) static::getEloquentQuery()->active()->count();
}
```
Shows the number of abandoned carts (not all carts — a converted cart isn't something a
staff member needs to keep noticing) next to "Carts" in the sidebar. `->count()` compiles to
a single `SELECT COUNT(*) ...` — confirmed via query log — no rows are ever loaded just to
render the badge.
---
## The view page runs the cart's full calculate pipeline — once
`ViewCart::resolveRecord()` calls `$cart->calculate()` before rendering, since `CartLine`'s
computed properties (`unitPrice`, `total`, etc.) and `Cart`'s own totals (`subTotal`, `total`,
...) are plain public properties populated as a side effect of that pipeline — never
persisted, so a plain Eloquent-fetched `Cart` has them all `null`/unset (see `docs/lunar.md`
Gotchas). This only runs on the single-record view page, not per row in the list table —
running the full 5-step pipeline for every row of a paginated list would be needless cost for
data the list doesn't display.
---
## Not built: staff editing a cart
The resource is deliberately read-only (`canCreate()` returns `false`, no edit page
registered). A cart is owned by the storefront's own add/update/remove flow
(`CartSession`/`Cart::add()`/etc.) — hand-editing cart contents from the admin panel isn't a
supported use case here.
---
## `CartService` — the storefront-facing API
`Modules\Core\Cart\Services\CartService` mirrors `Modules\Core\Catalog\Services\
ProductService`/`CollectionService`'s shape — one boboko-owned API a storefront calls, so
Lunar's own `CartSession`/`Cart` stay an implementation detail rather than something a
consuming app depends on directly.
- `current()` / `currentOrCreate()` — the latter force-creates a cart (`CartSession::manager()`),
the former doesn't (`CartSession::current()`, returns `null` for a fresh visitor — see
`docs/lunar.md`'s Cart gotchas).
- `addLine()` / `updateLine()` / `removeLine()` / `clear()` — thin wrappers over
`Cart::add()`/`updateLine()`/`remove()`/`clear()`. No boboko-owned exception types wrap
Lunar's own cart exceptions (`InvalidCartLineQuantityException`, `CartLineIdMismatchException`,
etc.) — they propagate as-is; a wrapper would add indirection with identical semantics.
- `applyCoupon()` / `removeCoupon()` — sets/clears `Cart::coupon_code` (there's no dedicated
Lunar action for this, unlike add/update/remove). `applyCoupon()` validates via
`Discounts::validateCoupon()` first and throws `Modules\Core\Cart\Exceptions\
InvalidCouponException` on a bad code — `CouponString`'s cast only normalizes casing, it
doesn't validate anything, so setting `coupon_code` directly would silently accept a bogus
code and just not discount anything once calculated.
- `saveForLater()` / `moveToCart()` / `activeLines()` / `savedLines()` — see "Save for later"
below.
Every mutating method returns the recalculated `Cart` (matching Lunar's own `Cart::add()`
etc., which already return `$this` after `refresh()->recalculate()`) and dispatches a
matching domain event.
### Events — Lunar dispatches none of its own
`Lunar` dispatches zero cart events — no "item added," no "cart created" (see
`docs/lunar.md`'s Cart gotchas). `CartService` fills that gap with its own, dispatched after
the underlying Lunar operation completes:
`CartLineAdded`, `CartLineUpdated`, `CartLineRemoved`, `CartCleared`, `CartCouponApplied`,
`CartCouponRemoved`, `CartLineSaved`, `CartLineMovedToCart` — all under
`Modules\Core\Cart\Events`. `CartAbandoned`/`CheckoutAbandoned` live under
`Modules\Core\Recovery\Events` instead, not `Cart`/`Checkout` — see "Abandonment detection"
below for why.
**None of these currently have a listener.** They're dispatched-but-unconsumed by design —
built so something downstream (reindexing, notifications, a future read-side reporting
service) has a hook to attach to, not because a concrete consumer exists today. This was a
deliberate decision, not an oversight — see the "don't build speculative infrastructure"
calls made elsewhere in this project (e.g. not wrapping Lunar's cart exceptions).
**Why not wired to Spatie's Activity Log:** `Cart`/`CartLine` already use Lunar's own
`LogsActivity` trait (Spatie's package, Lunar's defaults) — confirmed from source, this logs
model saves/deletes automatically, independent of actor. `Modules\Core\Logging\
ActivityLogService` (this project's own wrapper, used by e.g. `LogTranslationActivity`) is
hardcoded to the `staff` guard — correctly scoped for staff-driven writes (Filament admin
actions), but wrong for customer-driven cart activity, which would resolve `causedBy()` to
`null` every time. Both `ActivityLogService` and `Cart`/`CartLine`'s native `LogsActivity`
write to the **same** `log_name = 'lunar'` / `activity_log` table, with no built-in
separation beyond reading `causer_type` per row — a real limitation worth knowing about, but
not one this project is fixing by giving Cart a distinct `log_name`, since every other Lunar
model logs to `'lunar'` too and a Cart-only carve-out would just be inconsistent. The
intended fix, if this becomes a real need, is a read-side service that queries `activity_log`
and classifies by `causer_type`/`log_name` — not touching every write site.
### Save for later
A `CartLine` can be moved out of the purchasable cart without being deleted — flagged via
`meta.saved_for_later`, not a new column (matches the free-form-JSON pattern already used
elsewhere, e.g. `ProductOptionValue::meta`). `Modules\Core\Cart\Pipelines\
ZeroSavedForLaterPrice` (registered in `config('lunar.cart.pipelines.cart_lines')`, after the
stock `GetUnitPrice`) zeroes `unitPrice`/`unitPriceInclTax` for flagged lines **before**
Lunar's own `CalculateLines` pipeline step sums the cart — `CalculateLines` sums every
`CartLine` unconditionally with no meta-based exclusion of its own, so zeroing the price
upstream is what makes `Cart::subTotal`/`total` naturally correct without a second pass or
callers needing a different totals accessor.
`Lunar\Actions\Carts\UpdateCartLine` **replaces** the whole `meta` column on write (plain
`update(['meta' => $meta])`, not a merge) — `saveForLater()`/`moveToCart()` read the line's
existing meta and merge in the flag change before calling `Cart::updateLine()`, or an
unrelated meta key set by something else would be silently wiped.
### Coupons
See `CartService::applyCoupon()`/`removeCoupon()` above. `Lunar\Base\Casts\CouponString`
just upper-cases the code; `Lunar\Managers\DiscountManager::validateCoupon()` (via the
`Discounts` facade) is the actual check — does a matching `Discount` (type `AmountOff` or
`BuyXGetY`) exist, `active()`, with `max_uses` not exhausted.
---
## Abandonment detection
"Abandoned" is a **derived** state (`Cart::updated_at` older than
`config('core.cart.abandoned_after')`, default `1 hour`) — nothing transitions a cart into it
via a normal Eloquent write, so there's no model-event hook to dispatch from directly.
`Modules\Core\Cart\Commands\DetectAbandonedCarts` (registered on an hourly schedule by
`Modules\Core\Providers\CartServiceProvider`) is the only place that moment gets detected: it
queries the same two branches `ListCarts::getTabs()` uses (no order at all vs. draft order
never placed) and dispatches `Modules\Core\Recovery\Events\CartAbandoned`/`CheckoutAbandoned`
for anything currently stale.
### Cart/Checkout have zero abandonment-related writes — by design
`DetectAbandonedCarts` **only dispatches** — it never writes to `Cart`/`Order` at all. An
earlier version recorded an "already notified" marker on `Cart::meta`/`Order::meta` to avoid
refiring the same event every run, but that `->save()` call bumped `Cart::updated_at` as an
Eloquent side effect — since `updated_at` is also the field abandonment staleness is computed
from, the write **un-staled the very cart it had just marked abandoned**: confirmed live, a
cart that correctly fired `CartAbandoned` showed back up as "Ongoing," not "Abandoned Cart,"
on the very next tab-count check.
The fix wasn't to write the marker more carefully — it was to stop `Cart`/`Checkout` from
having any way to write abandonment state at all. Deduplication ("has this cart already been
notified") is deliberately **not** this command's job; it belongs to `Recovery` (not yet
built — see `docs/recovery-strategies.md`), which will own its own tracking table, keeping
`Cart`/`Order` permanently free of abandonment-related columns or `meta` keys.
**Current tradeoff, accepted deliberately**: until `Recovery` exists, every cart still
matching the "abandoned" query refires its event on every hourly run — there is no dedup at
all right now. That's fine today only because nothing consumes these events yet (see
"Events" above); it would need addressing before anything real listens for them.
---
## Recovery Sequences — design only, not built
See `docs/recovery-strategies.md` — a full marketing-strategy discussion and a first-pass
feature design for an admin-configurable sequence of "touches" (delay + optional discount +
label) per abandonment type. Explicitly parked as an open design question, not scoped for
implementation yet — whether this belongs under `Cart`, a new `Recovery`/`Marketing` concern,
and how far the touch model needs to flex (channel choice, value-based branching, segment
targeting) are all still undecided.
+58 -1
View File
@@ -554,7 +554,11 @@ Customer resolution order: session → `$user->latestCustomer()`.
```php
use Lunar\Facades\CartSession;
$cart = CartSession::current(); // calculates totals; returns null if no cart
$cart = CartSession::current(); // returns null unless a cart already exists in
// session — does NOT auto-create one (see Gotchas)
$cart = CartSession::manager(); // force-creates a cart if none exists yet — use
// this (or __call forwarding, see Gotchas) for
// "give me a cart to add to" flows
$cart->recalculate(); // force recalculation
CartSession::createOrder(); // creates order, removes cart from session
@@ -563,6 +567,39 @@ CartSession::forget(); // clear session (soft deletes cart by def
CartSession::forget(delete: false); // clear session, keep cart in DB
```
Session/identity: the active cart's id is stored under session key `lunar.cart_session.session_key`
(default `lunar_cart`). `CartSession`'s underlying manager (`Lunar\Managers\CartSessionManager`) —
not `Lunar\Base\CartSessionInterface`, which is stale/incomplete, see Gotchas — resolves the current
cart from that session key, falling back to the authenticated user's active cart
(`$user->carts()->active()->first()`) if the session has none.
### `config/lunar/cart_session.php`
| Key | Default | Meaning |
|---|---|---|
| `session_key` | `'lunar_cart'` | Laravel session key storing the active cart id. |
| `auto_create` | `false` | Whether `CartSession::current()` auto-creates a cart when none exists — it does **not**, by default (see Gotchas). |
| `allow_multiple_orders_per_cart` | `false` | If false, a cart with a completed order is abandoned in favor of a fresh cart on next fetch. |
| `delete_on_forget` | `true` | Whether `forget()` (called on logout) soft-deletes the cart — see the auth-policy note above. |
### `config/lunar/cart.php` (cart-line-relevant keys)
| Key | Default | Meaning |
|---|---|---|
| `auth_policy` | `'merge'` | Guest→user cart reconciliation on login: `merge` or `override`. |
| `pipelines.cart` | `CalculateLines, ApplyShipping, ApplyDiscounts, CalculateTax, Calculate` | Steps run on `$cart->calculate()`. |
| `pipelines.cart_lines` | `[GetUnitPrice::class]` | Steps run per-line before cart-level calc. |
| `actions.add_to_cart` | `AddOrUpdatePurchasable::class` | Swappable action behind `Cart::add()`. |
| `actions.get_existing_cart_line` | `GetExistingCartLine::class` | Line-matching logic for add-or-merge (see "Adding items" above). |
| `actions.update_cart_line` | `UpdateCartLine::class` | Behind `Cart::updateLine()`. |
| `actions.remove_from_cart` | `RemovePurchasable::class` | Behind `Cart::remove()`. |
| `validators.add_to_cart` | `[CartLineQuantity, CartLineStock]` | Run before add. |
| `validators.update_cart_line` | `[CartLineQuantity, CartLineStock]` | Run before update. |
| `validators.remove_from_cart` | `[]` | None by default. |
| `eager_load` | 7 relation paths (currency, `lines.purchasable.*`, `lines.cart.currency`) | Auto-eager-loaded whenever the session manager fetches a cart by id. Does **not** include `addresses`/`shippingAddress`/`billingAddress`, `discounts`, or `customer` — add these yourself if needed, to avoid N+1s. |
| `prune_tables.enabled` | `false` | Whether scheduled cart pruning runs. |
| `prune_tables.prune_interval` | `90` (days) | Age threshold for pruning. |
### Adding items
```php
@@ -573,6 +610,11 @@ $cart->addLines([
]);
```
`add()` matches an existing line by purchasable **and exact `meta` equality** (config
`lunar.cart.actions.get_existing_cart_line`, default `GetExistingCartLine`) — if it matches, the
existing line's quantity is incremented instead of a new line being created; any difference in
`meta` (e.g. a different chosen option) makes it a separate line for the same purchasable.
### Updating and removing
```php
@@ -664,6 +706,14 @@ class MyPipeline
`merge` — guest cart items combine with user's existing cart on login.
`override` — guest cart replaces user's cart.
This is wired via `Lunar\Listeners\CartSessionAuthListener`, listening on Laravel's own
`Illuminate\Auth\Events\Login`/`Logout`. On login, if the session already has a cart with no
`user_id` yet, it associates that cart to the user (running the policy above); if the session has
no cart at all, it looks up and resumes the user's own active cart instead. **On logout, it calls
`CartSession::forget()`** — which, per `cart_session.delete_on_forget` (default `true`), **soft-
deletes the cart**. A logged-in customer's cart is gone on logout unless that config is set to
`false`.
### Shipping options
```php
@@ -1209,3 +1259,10 @@ Real bugs/traps hit while building against Lunar in this package — not obvious
- **`Builder::paginateRaw()`'s `items()` is not a hit list on the Meilisearch driver.** It contains the *entire* raw response (`hits`, `query`, `processingTimeMs`, `hitsPerPage`, `page`, `totalPages`, `totalHits`) as one associative array. Treating `$paginator->items()` as a plain list (e.g. `collect($paginator->items())->values()`) silently produces 7 elements — the real hits array happens to land first, the rest are stray scalars from the other response keys — no error, just corrupted data. Pull `$paginator->items()['hits']` explicitly. `total()`/`perPage()`/`currentPage()`/`lastPage()` on the paginator are unaffected. See `Modules\Core\Catalog\Services\ProductService` / `docs/product-listing.md`.
- **`ProductOption`/`ProductOptionValue::$name` is not `attribute_data` — `translateAttribute('name')` silently returns null for them.** Unlike `Product`/`Collection`/`Brand`, their translated `name` is a plain locale-keyed array cast (`AsArrayObject`) directly on the column, not stored in `attribute_data`. `HasTranslations::translateAttribute()` only reads `attribute_data`, so calling it on these two models compiles fine and returns `null` with no error — read the array directly instead (`$value->name[$locale] ?? ...`). See `Modules\Core\Catalog\Services\ProductIndexer::translatedName()`.
- **A running `queue:work` process does not pick up an edited/newly-added Scout indexer class.** It loads PHP classes once at boot and keeps them for the process's lifetime. Symptoms: reindexing commands succeed with no errors, calling `toSearchableArray()` directly (e.g. via `artisan tinker`, which always boots fresh) returns the new fields correctly, but documents written via `$model->searchable()` through the live queue are still missing them. Restart the queue worker after deploying an indexer change — no code fix needed.
- **`CartSession::current()` returns `null` for a fresh visitor by default.** `cart_session.auto_create` defaults to `false`, so nothing auto-creates a cart just from checking `current()`. Use `CartSession::manager()` (force-creates) for an "add to cart" flow, or rely on the fact that `add()`/`remove()`/etc. auto-create via `__call` forwarding (next entry) — don't gate an add-to-cart button on `current() !== null`, it will be null for every guest who hasn't added anything yet.
- **`CartSession`'s facade/interface don't declare `add()`, `remove()`, `updateLine()`, `clear()`, etc. at all — they work anyway, via `__call` magic.** `CartSessionManager::__call()` forwards any undeclared method call straight to the underlying `Cart` model (auto-creating one first if needed). So `CartSession::add($variant, 2)` genuinely works, but neither the facade's `@method` docblock nor `Lunar\Base\CartSessionInterface` mention it — reading either in isolation makes it look unsupported. Trust the manager's source (`Lunar\Managers\CartSessionManager`), not the interface, which is also missing several real methods (`manager()`, `createOrder()`, the shipping-estimate methods) and has a stale signature for `current()`.
- **`Cart::calculate()` is a no-op if totals already look populated — even right after you mutated lines with raw Eloquent.** It's memoized via `isCalculated()` (true when `total` and every line's `total` are non-blank). Every built-in mutator (`add`, `remove`, `updateLine`, `clear`, `associate`, …) already calls `$this->refresh()->recalculate()` to force past this memo — but custom code that touches `CartLine` rows directly (raw `update()`, a queued job, a migration) must call `$cart->recalculate()` itself, or `total`/`subTotal`/etc. silently stay stale.
- **`CartLine`'s computed properties (`unitPrice`, `subTotal`, `total`, `taxAmount`, …) are plain public properties, not DB columns or Eloquent attributes.** A raw `CartLine::find($id)` (no `calculate()` having run on its owning cart) has all of these as `null`/unset — they only populate as a side effect of the owning `Cart`'s pipeline running. Don't read them off a line fetched outside of `CartSession`/`Cart::add()` etc. without calling `$cart->calculate()` first.
- **Logging out deletes the cart by default.** `CartSessionAuthListener::logout()` calls `CartSession::forget()`, and `cart_session.delete_on_forget` defaults to `true` — so a logged-in customer's cart is soft-deleted the moment they log out, guest or not. Set `delete_on_forget` to `false` in `config/lunar/cart_session.php` if carts should survive a logout.
- **Lunar dispatches no cart events at all** — no "item added," "cart created," "line removed," nothing under `Lunar\Events\Cart*`/`CartLine*` exists (unlike products/collections, which have their own Scout indexing hooks). The only reactive surface is `CartLineObserver` (`creating`/`updating`, and it only validates the purchasable type — doesn't dispatch anything). If a feature needs to react to cart changes (reindexing, abandoned-cart notifications, analytics), it has to be built from scratch on plain Eloquent model events (`CartLine::created`, etc.) — there's no Lunar-native pattern to hook into.
- **No Filament admin resource exists for `Cart`/`CartLine`.** Carts aren't visible anywhere in the admin panel except indirectly through an order's `cart` relationship once that cart has become an order. Don't assume there's an admin cart-viewer to check against when debugging — there isn't one.
+128
View File
@@ -0,0 +1,128 @@
# Cart/Checkout Recovery Strategies — Design Notes
**Status: open design discussion, not scoped or built.** This is a record of the
reasoning behind an eventual "Recovery Sequences" feature, kept so the discussion doesn't
have to be re-derived from scratch later. Nothing in this document is implemented.
See `docs/cart.md` for what's actually built today (the four-state cart classification,
`CartAbandoned`/`CheckoutAbandoned` events, `DetectAbandonedCarts`).
---
## Why Abandoned Cart and Abandoned Checkout need different strategies
Established in `docs/cart.md`: Abandoned Cart (no order ever started) is a weak purchase-intent
signal and often unreachable (no identity for a true guest). Abandoned Checkout (a draft order
exists, `placed_at IS NULL`) is a strong intent signal and usually reachable, since checkout
typically captures an email/address even for a guest.
That difference in intent and reachability drives genuinely different marketing strategy, not
just a different admin filter:
### Abandoned Cart strategy — re-engagement, not completion
- **On-site retargeting first** (exit-intent popups, "still thinking it over?" banners on
return visits) — often the only viable channel, since email may not exist yet.
- **Ad platform retargeting** (Meta/Google dynamic remarketing) is the dominant channel here
specifically because it works off a browser/device signal, not an email address — the one
thing reliably available for an anonymous cart.
- **Soft messaging** ("did you forget something?") rather than urgency-driven — intent is
weak, so aggressive discounting is often poor ROI: it trains browsers who were never close
to buying to expect a coupon.
- **Longer, gentler cadence** — a single reminder around 24h, maybe a second a few days out,
sometimes trigger-based (a price drop, back-in-stock) rather than a fixed schedule.
### Abandoned Checkout strategy — completion, not re-engagement
- **Speed matters most.** This is where the classic 1h/24h/72h recovery-email cadence lives —
conversion drops sharply with delay, since the shopper is often still in a "was about to
buy" mental state within the first hour.
- **Direct, urgency-framed messaging** ("complete your order"), sometimes showing cart
contents/total, occasionally a countdown or limited-time incentive on later touches.
- **Discount escalation pays off here** — a small incentive (free shipping, 10% off) on the
2nd/3rd touch is standard, because it's nudging someone who already decided to buy past
whatever blocked them (price shock, a broken payment step, indecision on shipping cost) —
not manufacturing demand from nothing.
- **SMS is more viable** — checkout often captures a phone number, and the higher intent
justifies a more direct channel than for cart-stage.
---
## The broader strategy space (beyond cadence + discount)
Raised as context for how far a "Recovery Sequence" feature might eventually need to flex,
without committing to building any of it yet:
**Message-content strategies**
- Social proof ("X people have this in their cart," reviews shown in the reminder)
- Scarcity/urgency framing (low-stock count, countdown timer on an offer)
- Personalized alternatives — a cheaper or complementary item instead of just re-showing the
abandoned one, useful when the likely blocker was price
**Channel strategies**
- Email (the baseline; nothing built yet — see `docs/cart.md`'s "Recovery Sequences" section)
- SMS — checkout-stage specifically, opt-in required
- Push notifications — not relevant yet given this project's storefront maturity, noted for
completeness
- On-site remarketing (banner/modal on the shopper's next visit) — doesn't require email at
all, arguably the highest-value channel for Abandoned Cart specifically
- Ad platform sync (pushing abandoned-cart product data to a custom audience for paid retargeting)
**Escalation/segmentation strategies**
- Value-based branching — a high-value abandoned checkout might skip straight to a bigger
incentive rather than waiting through a full ladder
- Repeat-abandoner suppression — a customer who's abandoned 3+ times without ever completing
either stops receiving emails (fatigue/spam risk) or gets a different tactic (e.g. a "what
stopped you?" survey) instead of another discount
- New vs. returning customer branching — a first-time visitor's abandoned cart might warrant
"welcome discount" framing instead of a generic recovery email, since the blocker was
likely trust/unfamiliarity rather than price
**Timing refinement**
- Time-of-day/timezone-aware sending (don't fire a touch at 3am local time even if the delay
technically elapsed)
- Cart-content-triggered timing — a fast-moving/low-stock item might warrant an earlier, more
urgent first touch than a cart of always-in-stock staples
---
## First-pass feature shape (discussed, not finalized)
An admin defines, independently per abandonment type (Abandoned Cart, Abandoned Checkout), an
ordered sequence of **touches**. Each touch is three ideas:
1. **How long to wait** since the abandonment began
2. **What offer to attach**, optional — reusing whatever `Discount` already exists in the
system rather than inventing a new pricing concept
3. **A label**, so staff can see what a touch represents in the admin UI
The system continuously re-evaluates every abandoned cart/checkout against its sequence, and
when a cart becomes due for the next touch it hasn't had yet, that becomes a signal — this
feature's responsibility ends there. Actually sending anything (email, SMS, on-site banner) is
explicitly out of scope for this feature; something else, not yet designed, would consume that
signal.
### What this requires that isn't built yet
- **A fixed "abandonment began at" timestamp**, captured once and never re-derived — a
sequence needs to schedule touches from a stable starting point, not from `Cart::updated_at`,
which keeps moving every time the cart (or its own bookkeeping) is written to. This is the
same underlying issue as the known bug in `docs/cart.md`'s "Abandonment detection" section —
fixing that bug properly (freezing the abandonment moment) is very likely a prerequisite for
this feature, not a separate concern.
- **Re-evaluation, not one-shot detection** — `DetectAbandonedCarts` today marks a cart
abandoned once and stops; a sequence needs a cart to be revisited on every scheduler run to
check "which touch, if any, is now due," for as long as it stays unrecovered.
### Still undecided
- **Which concern this belongs under.** Not `Cart` (it's not a cart-mechanics concern) —
candidates raised: a new `Recovery` concern, or `Marketing`. Not decided.
- **How far the touch model needs to flex.** The three-idea shape above (delay, discount,
label) covers cadence + discount escalation cleanly, but doesn't yet accommodate channel
choice, value-based branching, or segment targeting from the broader strategy list above.
Whether those get folded into the touch model, layered on top some other way, or deliberately
left out of v1 is unresolved.
- **Whether "recovery" is cart/checkout-specific at all**, or a more general "scheduled
customer touch based on a triggering condition" mechanism that cart/checkout abandonment
happens to be the first use case for.
@@ -0,0 +1,3 @@
<x-filament-panels::page>
{{ $this->table }}
</x-filament-panels::page>
@@ -0,0 +1,84 @@
<?php
namespace Modules\Core\Cart\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Event;
use Lunar\Models\Cart;
use Modules\Core\Cart\Filament\Resources\CartResource;
use Modules\Core\Recovery\Events\CartAbandoned;
use Modules\Core\Recovery\Events\CheckoutAbandoned;
/**
* "Abandoned" is a derived state (Cart::updated_at older than
* config('core.cart.abandoned_after')) — nothing transitions a cart into it
* via a normal Eloquent write, so there's no model-event hook to dispatch
* CartAbandoned/CheckoutAbandoned from directly. This command is the only
* place that moment gets detected; run it on a schedule (see docs/cart.md).
*
* Splits Cart::scopeActive()'s two branches into their own events —
* see CartAbandoned/CheckoutAbandoned's docblocks for why they're distinct,
* not one combined "abandoned" state: a cart with no order at all is a much
* weaker purchase-intent signal than one with a draft order that was never
* placed.
*
* Deliberately does NOT write anything to Cart/Order — dispatch only. An
* earlier version recorded an "already notified" marker on Cart::meta/
* Order::meta, but that write bumped updated_at as an Eloquent side effect,
* which un-staled the very cart being marked abandoned (the same field
* abandonment staleness is computed from) — see docs/cart.md's former
* "Known bug" note. Cart/Checkout must have no way of writing abandonment
* state at all; every cart still matching the query below refires its event
* on every run until Recovery (not yet built — see
* docs/recovery-strategies.md) owns its own dedup/tracking table.
*/
class DetectAbandonedCarts extends Command
{
protected $signature = 'boboko:cart:detect-abandoned';
protected $description = 'Dispatch CartAbandoned/CheckoutAbandoned for carts that just crossed the abandonment threshold.';
public function handle(): void
{
$cutoff = CartResource::abandonedCutoff();
$cartsAbandoned = 0;
$checkoutsAbandoned = 0;
Cart::query()
->whereDoesntHave('orders')
->where('updated_at', '<=', $cutoff)
->with('lines')
->chunkById(200, function ($carts) use (&$cartsAbandoned) {
foreach ($carts as $cart) {
if ($cart->lines->isEmpty()) {
continue;
}
Event::dispatch(new CartAbandoned($cart));
$cartsAbandoned++;
}
});
Cart::query()
->whereHas('orders', fn ($query) => $query->whereNull('placed_at'))
->where('updated_at', '<=', $cutoff)
->with(['orders' => fn ($query) => $query->whereNull('placed_at')])
->chunkById(200, function ($carts) use (&$checkoutsAbandoned) {
foreach ($carts as $cart) {
$order = $cart->orders->first();
if ($order === null) {
continue;
}
Event::dispatch(new CheckoutAbandoned($cart, $order));
$checkoutsAbandoned++;
}
});
$this->components->info("Dispatched CartAbandoned for {$cartsAbandoned} cart(s), CheckoutAbandoned for {$checkoutsAbandoned} checkout(s).");
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace Modules\Core\Cart\Events;
use Lunar\Models\Cart;
class CartCleared
{
/**
* @param array<int, array{id: int, purchasable_type: string, purchasable_id: int, quantity: int, meta: array}> $lines
* Snapshot of every line that was in the cart before clearing — Cart::clear()
* deletes all rows directly, so nothing here can be fresh CartLine instances.
*/
public function __construct(
public readonly Cart $cart,
public readonly array $lines,
) {}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace Modules\Core\Cart\Events;
use Lunar\Models\Cart;
class CartCouponApplied
{
public function __construct(
public readonly Cart $cart,
public readonly string $code,
) {}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace Modules\Core\Cart\Events;
use Lunar\Models\Cart;
class CartCouponRemoved
{
public function __construct(
public readonly Cart $cart,
public readonly string $code,
) {}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace Modules\Core\Cart\Events;
use Lunar\Models\Cart;
use Lunar\Models\CartLine;
class CartLineAdded
{
public function __construct(
public readonly Cart $cart,
public readonly CartLine $line,
) {}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace Modules\Core\Cart\Events;
use Lunar\Models\Cart;
use Lunar\Models\CartLine;
/**
* The reverse of CartLineSaved — a previously saved-for-later line moved back
* into the purchasable cart (now counted in totals again).
*/
class CartLineMovedToCart
{
public function __construct(
public readonly Cart $cart,
public readonly CartLine $line,
) {}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace Modules\Core\Cart\Events;
use Lunar\Models\Cart;
class CartLineRemoved
{
/**
* @param array{id: int, purchasable_type: string, purchasable_id: int, quantity: int, meta: array} $line
* Snapshot of the removed line — the row is already deleted by the time this
* event dispatches, so nothing here can be a fresh CartLine model instance.
*/
public function __construct(
public readonly Cart $cart,
public readonly array $line,
) {}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace Modules\Core\Cart\Events;
use Lunar\Models\Cart;
use Lunar\Models\CartLine;
/**
* A line was moved OUT of the purchasable cart and into "saved for later" —
* not a removal (the row still exists), but distinct from CartLineUpdated
* since it's a state transition worth its own hook (e.g. abandoned-cart
* recovery treating a saved line very differently from a deleted one).
*/
class CartLineSaved
{
public function __construct(
public readonly Cart $cart,
public readonly CartLine $line,
) {}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace Modules\Core\Cart\Events;
use Lunar\Models\Cart;
use Lunar\Models\CartLine;
class CartLineUpdated
{
/**
* @param array{quantity: int, meta: array} $old Snapshot before the update.
*/
public function __construct(
public readonly Cart $cart,
public readonly CartLine $line,
public readonly array $old,
) {}
}
@@ -0,0 +1,19 @@
<?php
namespace Modules\Core\Cart\Exceptions;
use RuntimeException;
/**
* Thrown by CartService::applyCoupon() when the given code doesn't match any
* currently-active, non-exhausted Discount — Lunar's own
* Discounts::validateCoupon() only returns a bool, it has no matching
* exception type of its own to reuse here.
*/
class InvalidCouponException extends RuntimeException
{
public function __construct(public readonly string $code)
{
parent::__construct("The coupon code \"{$code}\" is not valid.");
}
}
@@ -0,0 +1,120 @@
<?php
namespace Modules\Core\Cart\Filament\Resources;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
use Lunar\Admin\Filament\Resources\CustomerResource;
use Lunar\Models\Cart;
use Modules\Core\Cart\Filament\Resources\CartResource\Pages;
/**
* Read-only — a cart is managed entirely through the storefront (add/update/remove
* line, checkout), never hand-edited by staff. Scoped to carts with a known
* `user_id`/`customer_id` only: an anonymous guest's session cart carries no
* identity a staff member could act on (no name, no email, nothing to follow up
* with), so listing every such row would be noise, not a real admin capability —
* see docs/cart.md for the reasoning (Lunar itself ships no cart admin view at all
* to follow a precedent from).
*/
class CartResource extends Resource
{
protected static ?string $model = Cart::class;
protected static ?string $navigationIcon = 'heroicon-o-shopping-cart';
protected static ?string $navigationGroup = 'Sales';
protected static ?string $modelLabel = 'Cart';
protected static ?string $pluralModelLabel = 'Carts';
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()
->where(fn (Builder $query) => $query->whereNotNull('user_id')->orWhereNotNull('customer_id'));
}
/**
* Count only, not a fetch — no rows are loaded. Combines BOTH abandoned
* states (`active()` already covers "no order at all" and "draft order,
* never placed" together — see ListCarts::getTabs()'s "Abandoned Cart" /
* "Abandoned Checkout" tabs for where they're split apart), not "Ongoing"
* — the badge is meant to answer "how many carts might need following up
* on," not the total including ones someone is actively shopping in right
* now.
*/
public static function getNavigationBadge(): ?string
{
return (string) static::getEloquentQuery()->active()->where('updated_at', '<=', static::abandonedCutoff())->count();
}
/**
* `Cart::scopeActive()` (not-yet-converted-to-an-order carts) mixes two very
* different things together: a cart someone is actively shopping in right now,
* and one that's genuinely been left behind. Lunar tracks no time-based
* staleness signal of its own — `Cart::updated_at` plus a configurable
* threshold (`config('core.cart.abandoned_after')`, default 1 hour) is what
* this resource uses to tell them apart. A cart with no recent activity is
* "Abandoned"; anything more recent is "Ongoing".
*/
public static function abandonedCutoff(): Carbon
{
return now()->sub(config('core.cart.abandoned_after', '1 hour'));
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('id')
->label('Cart')
->sortable(),
Tables\Columns\TextColumn::make('customer.full_name')
->label('Customer')
->placeholder('—')
->searchable()
->url(fn (Cart $record) => $record->customer_id !== null
? CustomerResource::getUrl('view', ['record' => $record->customer_id])
: null),
Tables\Columns\TextColumn::make('user.email')
->label('User')
->placeholder('—')
->searchable(),
Tables\Columns\TextColumn::make('lines_count')
->label('Lines')
->counts('lines')
->sortable(),
Tables\Columns\TextColumn::make('lines_sum_quantity')
->label('Items')
->sum('lines', 'quantity')
->sortable(),
Tables\Columns\TextColumn::make('currency.code')
->label('Currency'),
Tables\Columns\TextColumn::make('updated_at')
->label('Last activity')
->dateTime()
->sortable(),
])
->actions([
Tables\Actions\ViewAction::make(),
])
->defaultSort('updated_at', 'desc');
}
public static function getPages(): array
{
return [
'index' => Pages\ListCarts::route('/'),
'view' => Pages\ViewCart::route('/{record}'),
];
}
public static function canCreate(): bool
{
return false;
}
}
@@ -0,0 +1,55 @@
<?php
namespace Modules\Core\Cart\Filament\Resources\CartResource\Pages;
use Filament\Resources\Components\Tab;
use Filament\Resources\Pages\ListRecords;
use Illuminate\Database\Eloquent\Builder;
use Modules\Core\Cart\Filament\Resources\CartResource;
class ListCarts extends ListRecords
{
protected static string $resource = CartResource::class;
/**
* `Cart::completed_at` is declared/cast on the model but never actually written
* anywhere in Lunar core — it's dead, not a real "did this convert" signal.
* "Completed" instead means the cart has an order with `placed_at` set (a
* placed, not just drafted, order).
*
* `Cart::scopeActive()` (not yet converted to an order) actually mixes two
* distinct states: no order started at all, vs. a draft order exists
* (`placed_at IS NULL`) but was never placed — checkout was started, not
* finished. That's a real difference in purchase intent (a cart with a
* draft order is a much stronger signal than one with no order at all) and
* in reachability (checkout usually captures an email even for a guest),
* so they get separate tabs rather than one combined "no order yet"
* bucket — same distinction Modules\Core\Recovery\Events\CartAbandoned /
* Modules\Core\Recovery\Events\CheckoutAbandoned draw.
*
* "Ongoing" vs the two abandoned tabs all split on `updated_at` against
* `CartResource::abandonedCutoff()` — Lunar has no time-based staleness
* signal of its own, so recent activity is the only thing distinguishing a
* cart someone is shopping in right now from one genuinely left behind.
*/
public function getTabs(): array
{
return [
'ongoing' => Tab::make('Ongoing')
->modifyQueryUsing(fn (Builder $query) => $query->active()->where('updated_at', '>', CartResource::abandonedCutoff())),
'abandoned_cart' => Tab::make('Abandoned Cart')
->modifyQueryUsing(fn (Builder $query) => $query
->whereDoesntHave('orders')
->where('updated_at', '<=', CartResource::abandonedCutoff())),
'abandoned_checkout' => Tab::make('Abandoned Checkout')
->modifyQueryUsing(fn (Builder $query) => $query
->whereHas('orders', fn (Builder $query) => $query->whereNull('placed_at'))
->where('updated_at', '<=', CartResource::abandonedCutoff())),
'completed' => Tab::make('Completed')
->modifyQueryUsing(fn (Builder $query) => $query->whereHas(
'orders',
fn (Builder $query) => $query->whereNotNull('placed_at'),
)),
];
}
}
@@ -0,0 +1,112 @@
<?php
namespace Modules\Core\Cart\Filament\Resources\CartResource\Pages;
use Filament\Actions\Action;
use Filament\Infolists\Components\RepeatableEntry;
use Filament\Infolists\Components\Section;
use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Infolist;
use Filament\Resources\Pages\ViewRecord;
use Lunar\Admin\Filament\Resources\CustomerResource;
use Lunar\Models\Cart;
use Lunar\Models\CartLine;
use Modules\Core\Cart\Filament\Resources\CartResource;
class ViewCart extends ViewRecord
{
protected static string $resource = CartResource::class;
protected function getHeaderActions(): array
{
return [
Action::make('viewCustomer')
->label('View Customer')
->icon('heroicon-o-user')
->url(fn (Cart $record) => CustomerResource::getUrl('view', ['record' => $record->customer_id]))
->visible(fn (Cart $record) => $record->customer_id !== null),
];
}
/**
* Cart's computed properties (subTotal/total/etc.) are plain public properties
* populated as a side effect of the pipeline calculate() runs — never persisted,
* so they don't exist on a plain Eloquent-fetched record. Calculated once here
* (a single view page load), not per-row in the list table, since running the
* full pipeline for every row of a paginated table would be expensive for no
* real benefit — see docs/lunar.md's Cart gotchas.
*/
protected function resolveRecord(int|string $key): Cart
{
/** @var Cart $cart */
$cart = parent::resolveRecord($key);
return $cart->calculate();
}
public function infolist(Infolist $infolist): Infolist
{
return $infolist
->schema([
Section::make('Cart')
->columns(3)
->schema([
TextEntry::make('id'),
TextEntry::make('customer.full_name')
->label('Customer')
->placeholder('—')
->url(fn (Cart $record) => $record->customer_id !== null
? CustomerResource::getUrl('view', ['record' => $record->customer_id])
: null),
TextEntry::make('user.email')
->label('User')
->placeholder('—'),
TextEntry::make('currency.code')
->label('Currency'),
TextEntry::make('completedOrderPlacedAt')
->label('Ordered at')
->state(fn (Cart $record) => $record->orders()->whereNotNull('placed_at')->value('placed_at'))
->dateTime()
->placeholder('Not ordered'),
TextEntry::make('updated_at')
->label('Last activity')
->dateTime(),
]),
Section::make('Lines')
->schema([
RepeatableEntry::make('lines')
->hiddenLabel()
->schema([
TextEntry::make('purchasable.sku')
->label('SKU')
->placeholder('—'),
TextEntry::make('quantity'),
TextEntry::make('unitPrice')
->label('Unit price')
->formatStateUsing(fn (CartLine $record) => $record->unitPrice?->formatted() ?? '—'),
TextEntry::make('total')
->label('Line total')
->formatStateUsing(fn (CartLine $record) => $record->total?->formatted() ?? '—'),
])
->columns(4),
]),
Section::make('Totals')
->columns(3)
->schema([
TextEntry::make('subTotal')
->label('Subtotal')
->formatStateUsing(fn (Cart $record) => $record->subTotal?->formatted() ?? '—'),
TextEntry::make('discountTotal')
->label('Discount')
->formatStateUsing(fn (Cart $record) => $record->discountTotal?->formatted() ?? '—'),
TextEntry::make('taxTotal')
->label('Tax')
->formatStateUsing(fn (Cart $record) => $record->taxTotal?->formatted() ?? '—'),
TextEntry::make('total')
->label('Total')
->formatStateUsing(fn (Cart $record) => $record->total?->formatted() ?? '—')
->weight('bold'),
]),
]);
}
}
@@ -0,0 +1,33 @@
<?php
namespace Modules\Core\Cart\Pipelines;
use Closure;
use Lunar\DataTypes\Price;
use Lunar\Models\Contracts\CartLine as CartLineContract;
/**
* Runs in config('lunar.cart.pipelines.cart_lines'), after GetUnitPrice —
* zeroes out unitPrice/unitPriceInclTax for any line flagged
* meta.saved_for_later, BEFORE Lunar's own CalculateLines pipeline step reads
* unitPrice to compute subTotal/total. A saved-for-later item is deliberately
* parked, not pending purchase, so it shouldn't inflate Cart::total — and
* since CalculateLines sums every CartLine unconditionally with no meta-based
* exclusion of its own, zeroing the price here (rather than patching subTotal
* after the fact) is what makes every downstream total naturally correct
* without a second pass.
*/
class ZeroSavedForLaterPrice
{
public function handle(CartLineContract $cartLine, Closure $next): mixed
{
if ($cartLine->meta['saved_for_later'] ?? false) {
$currency = $cartLine->cart->currency;
$cartLine->unitPrice = new Price(0, $currency, 1);
$cartLine->unitPriceInclTax = new Price(0, $currency, 1);
}
return $next($cartLine);
}
}
+250
View File
@@ -0,0 +1,250 @@
<?php
namespace Modules\Core\Cart\Services;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Event;
use Lunar\Actions\Carts\GetExistingCartLine;
use Lunar\Base\Purchasable;
use Lunar\Facades\CartSession;
use Lunar\Facades\Discounts;
use Lunar\Models\Cart;
use Lunar\Models\CartLine;
use Modules\Core\Cart\Events\CartCleared;
use Modules\Core\Cart\Events\CartCouponApplied;
use Modules\Core\Cart\Events\CartCouponRemoved;
use Modules\Core\Cart\Events\CartLineAdded;
use Modules\Core\Cart\Events\CartLineMovedToCart;
use Modules\Core\Cart\Events\CartLineRemoved;
use Modules\Core\Cart\Events\CartLineSaved;
use Modules\Core\Cart\Events\CartLineUpdated;
use Modules\Core\Cart\Exceptions\InvalidCouponException;
/**
* Storefront-facing cart operations, mirroring Modules\Core\Catalog\Services\
* ProductService/CollectionService's shape — one boboko-owned API a storefront
* calls, so Lunar's own CartSession/Cart stay an implementation detail rather
* than something a consuming app depends on directly.
*
* Every mutating method dispatches a matching domain event
* (Modules\Core\Cart\Events\*) after the underlying Lunar operation completes —
* Lunar itself dispatches zero cart events (see docs/lunar.md's Cart gotchas),
* so without this, nothing in a consuming app has anything to react to when a
* cart actually changes (reindexing, notifications, analytics, etc.).
*
* All mutating methods return the recalculated Cart — matching Lunar's own
* Cart::add()/updateLine()/etc., which already return $this after
* refresh()->recalculate() — so a caller gets fresh totals in the same call,
* no second fetch needed.
*/
class CartService
{
/**
* The current session's cart, or null if none exists yet. Does NOT
* auto-create one — see currentOrCreate() for that.
*/
public function current(): ?Cart
{
return CartSession::current();
}
/**
* The current session's cart, creating one if none exists yet — the right
* call for "add to cart" style flows where a cart must exist by the time
* the method returns.
*/
public function currentOrCreate(): Cart
{
return CartSession::manager();
}
public function addLine(Purchasable $purchasable, int $quantity = 1, array $meta = []): Cart
{
$cart = $this->currentOrCreate()->add($purchasable, $quantity, $meta);
$line = app(config('lunar.cart.actions.get_existing_cart_line', GetExistingCartLine::class))
->execute($cart, $purchasable, $meta);
if ($line !== null) {
Event::dispatch(new CartLineAdded($cart, $line));
}
return $cart;
}
public function updateLine(int $cartLineId, int $quantity, ?array $meta = null): Cart
{
$before = CartLine::findOrFail($cartLineId);
$old = ['quantity' => $before->quantity, 'meta' => $before->meta->toArray()];
$cart = $this->currentOrCreate()->updateLine($cartLineId, $quantity, $meta);
$line = $cart->lines->firstWhere('id', $cartLineId);
if ($line !== null) {
Event::dispatch(new CartLineUpdated($cart, $line, $old));
}
return $cart;
}
public function removeLine(int $cartLineId): Cart
{
$line = CartLine::findOrFail($cartLineId);
$snapshot = $this->snapshotLine($line);
$cart = $this->currentOrCreate()->remove($cartLineId);
Event::dispatch(new CartLineRemoved($cart, $snapshot));
return $cart;
}
public function clear(): Cart
{
$cart = $this->currentOrCreate();
$snapshots = $cart->lines->map($this->snapshotLine(...))->all();
$cart = $cart->clear();
Event::dispatch(new CartCleared($cart, $snapshots));
return $cart;
}
/**
* Sets the cart's coupon code, which the ApplyDiscounts pipeline step picks
* up on the next calculate() — there's no dedicated Lunar action for this
* (unlike add/update/remove, coupon_code is a plain cast attribute), so
* this is the closest thing to one for a consuming app to call.
*
* Validated via Discounts::validateCoupon() (does a matching, currently
* active, non-exhausted Discount exist?) before it's set — CouponString's
* cast only normalizes casing, it doesn't validate anything, so setting
* coupon_code directly would silently accept a bogus code and just not
* discount anything once calculated.
*
* @throws InvalidCouponException if the code doesn't match a valid, active,
* non-exhausted Discount
*/
public function applyCoupon(string $code): Cart
{
if (! Discounts::validateCoupon($code)) {
throw new InvalidCouponException($code);
}
$cart = $this->currentOrCreate();
$cart->coupon_code = $code;
$cart->save();
$cart = $cart->recalculate();
Event::dispatch(new CartCouponApplied($cart, $cart->coupon_code));
return $cart;
}
public function removeCoupon(): Cart
{
$cart = $this->currentOrCreate();
$code = $cart->coupon_code;
if ($code === null) {
return $cart;
}
$cart->coupon_code = null;
$cart->save();
$cart = $cart->recalculate();
Event::dispatch(new CartCouponRemoved($cart, $code));
return $cart;
}
/**
* Lines currently counted toward the cart's totals — everything except
* ones flagged meta.saved_for_later (see savedLines()). This is the set a
* cart page's main list / checkout would iterate, since a saved line
* isn't pending purchase.
*
* @return Collection<int, CartLine>
*/
public function activeLines(?Cart $cart = null): Collection
{
$cart ??= $this->currentOrCreate();
return $cart->lines->reject(fn (CartLine $line) => $line->meta['saved_for_later'] ?? false)->values();
}
/**
* Lines a shopper has deliberately parked rather than deleted — excluded
* from Cart totals (see Modules\Core\Cart\Pipelines\ZeroSavedForLaterPrice)
* and from activeLines(). A cart page's "Saved for later" section iterates
* this set.
*
* @return Collection<int, CartLine>
*/
public function savedLines(?Cart $cart = null): Collection
{
$cart ??= $this->currentOrCreate();
return $cart->lines->filter(fn (CartLine $line) => $line->meta['saved_for_later'] ?? false)->values();
}
/**
* Moves a line OUT of the purchasable cart without deleting it — it stays
* on the cart (still visible, still re-addable) but is excluded from
* totals via meta.saved_for_later, zeroed by ZeroSavedForLaterPrice before
* Lunar's own CalculateLines sums the cart (which has no meta-based
* exclusion of its own).
*/
public function saveForLater(int $cartLineId): Cart
{
$line = CartLine::findOrFail($cartLineId);
$meta = [...$line->meta->toArray(), 'saved_for_later' => true];
$cart = $this->currentOrCreate()->updateLine($cartLineId, $line->quantity, $meta);
$line = $cart->lines->firstWhere('id', $cartLineId);
if ($line !== null) {
Event::dispatch(new CartLineSaved($cart, $line));
}
return $cart;
}
/**
* The reverse of saveForLater() — moves a line back into the purchasable
* cart, counted in totals again.
*/
public function moveToCart(int $cartLineId): Cart
{
$line = CartLine::findOrFail($cartLineId);
$meta = [...$line->meta->toArray(), 'saved_for_later' => false];
$cart = $this->currentOrCreate()->updateLine($cartLineId, $line->quantity, $meta);
$line = $cart->lines->firstWhere('id', $cartLineId);
if ($line !== null) {
Event::dispatch(new CartLineMovedToCart($cart, $line));
}
return $cart;
}
/**
* @return array{id: int, purchasable_type: string, purchasable_id: int, quantity: int, meta: array}
*/
private function snapshotLine(CartLine $line): array
{
return [
'id' => $line->id,
'purchasable_type' => $line->purchasable_type,
'purchasable_id' => $line->purchasable_id,
'quantity' => $line->quantity,
'meta' => $line->meta->toArray(),
];
}
}
+15 -1
View File
@@ -8,20 +8,28 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\Mail;
use Lunar\Admin\Filament\Resources\ProductOptionResource;
use Lunar\Admin\Filament\Resources\ProductOptionResource\RelationManagers\ValuesRelationManager;
use Lunar\Admin\Filament\Resources\OrderResource;
use Lunar\Admin\Filament\Resources\ProductResource;
use Lunar\Admin\Filament\Resources\StaffResource;
use Lunar\Admin\Models\Staff as LunarStaff;
use Lunar\Admin\Support\Facades\LunarPanel;
use Lunar\Models\Product;
use Lunar\Shipping\Filament\Resources\ShippingMethodResource;
use Lunar\Shipping\Filament\Resources\ShippingMethodResource\Pages\ListShippingMethod;
use Lunar\Shipping\ShippingPlugin;
use Modules\Core\Auth\Extensions\StaffResourceExtension;
use Modules\Core\Auth\Filament\Pages\Login;
use Modules\Core\Auth\Mail\InviteMail;
use Modules\Core\Cart\Filament\Resources\CartResource;
use Modules\Core\Catalog\Filament\Extensions\ProductOptionResourceExtension;
use Modules\Core\Catalog\Filament\Extensions\ValuesRelationManagerExtension;
use Modules\Core\Localization\Filament\Resources\LanguageLineResource;
use Modules\Core\Review\Filament\Extensions\ProductResourceExtension;
use Modules\Core\Review\Models\ProductReview;
use Modules\Core\Shipping\Extensions\OrderViewExtension;
use Modules\Core\Shipping\Extensions\ShippingMethodListExtension;
use Modules\Core\Shipping\Extensions\ShippingMethodResourceExtension;
use Modules\Core\Shipping\Filament\Pages\ManagePickupManifests;
class CorePlugin implements Plugin
{
@@ -39,14 +47,20 @@ class CorePlugin implements Plugin
->login(Login::class)
->resources([
LanguageLineResource::class,
CartResource::class,
])
->plugin(ShippingPlugin::make());
->plugin(ShippingPlugin::make())
->plugin(ShippingPlugin::make())
->pages([ManagePickupManifests::class]);
LunarPanel::extensions([
StaffResource::class => StaffResourceExtension::class,
ProductResource::class => ProductResourceExtension::class,
ProductOptionResource::class => ProductOptionResourceExtension::class,
ValuesRelationManager::class => ValuesRelationManagerExtension::class,
ShippingMethodResource::class => ShippingMethodResourceExtension::class,
ListShippingMethod::class => ShippingMethodListExtension::class,
OrderResource\Pages\ManageOrder::class => OrderViewExtension::class,
]);
Product::macro('reviews', function (): HasMany {
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace Modules\Core\Providers;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Support\ServiceProvider;
use Modules\Core\Cart\Commands\DetectAbandonedCarts;
class CartServiceProvider extends ServiceProvider
{
public function boot(): void
{
if ($this->app->runningInConsole()) {
$this->commands([DetectAbandonedCarts::class]);
}
$this->app->booted(function () {
$this->app->make(Schedule::class)
->command(DetectAbandonedCarts::class)
->hourly();
});
}
}
+102
View File
@@ -0,0 +1,102 @@
<?php
namespace Modules\Core\Providers;
use Illuminate\Console\Scheduling\Schedule as ConsoleSchedule;
use Illuminate\Support\ServiceProvider;
use Livewire\Livewire;
use Livewire\Mechanisms\ComponentRegistry;
use Lunar\Models\Order;
use Lunar\Shipping\Facades\Shipping;
use Lunar\Shipping\Filament\Resources\ShippingZoneResource\Pages\ManageShippingRates as VendorManageShippingRates;
use Lunar\Shipping\Models\ShippingMethod;
use Modules\Core\Shipping\Carriers\Acs\AcsClient;
use Modules\Core\Shipping\Carriers\Acs\AcsFulfillmentService;
use Modules\Core\Shipping\Carriers\Acs\AcsRateDriver;
use Modules\Core\Shipping\Carriers\Acs\Jobs\WarmAcsAreaCacheJob;
use Modules\Core\Shipping\Carriers\BoxNow\BoxNowClient;
use Modules\Core\Shipping\Carriers\BoxNow\BoxNowFulfillmentService;
use Modules\Core\Shipping\Carriers\BoxNow\BoxNowRateDriver;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\Filament\Pages\ManageShippingRates;
use Modules\Core\Shipping\Jobs\PollShipmentTrackingJob;
use Modules\Core\Shipping\Models\Shipment;
class ShippingServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->mergeConfigFrom(__DIR__ . '/../../config/shippingCarriers/acs.php', 'acs');
$this->mergeConfigFrom(__DIR__ . '/../../config/shippingCarriers/boxnow.php', 'boxnow');
$this->app->singleton(AcsClient::class, fn () => new AcsClient(config('acs')));
$this->app->singleton(BoxNowClient::class, fn () => new BoxNowClient(config('boxnow')));
$this->app->bind(CarrierFulfillmentInterface::class, function ($app, array $params) {
return match ($params['carrier'] ?? null) {
'acs' => $app->make(AcsFulfillmentService::class),
'box-now' => $app->make(BoxNowFulfillmentService::class),
default => null,
};
});
// The vendor Rates page has no extension hook, so we swap it for
// our subclass everywhere. Route::get($path, VendorClass::class)
// instantiates the vendor class directly via the container for the
// initial full-page load (bypassing Livewire's component registry
// entirely), so this container bind is required in addition to the
// Livewire::component() re-registration below — the bind covers
// first load, the Livewire registration covers every AJAX
// round-trip (form submits, table interactions) afterwards.
$this->app->bind(VendorManageShippingRates::class, ManageShippingRates::class);
}
public function boot(): void
{
$this->publishes([
__DIR__ . '/../../config/shippingCarriers/acs.php' => config_path('shippingCarriers/acs.php'),
__DIR__ . '/../../config/shippingCarriers/boxnow.php' => config_path('shippingCarriers/boxnow.php'),
], 'core-config');
Order::resolveRelationUsing('shipments', function ($order) {
return $order->hasMany(Shipment::class);
});
// Deferred: the Shipping facade resolves a binding registered in
// lunarphp/table-rate-shipping's own ShippingServiceProvider::boot(),
// and provider boot order between packages isn't guaranteed.
$this->app->booted(function () {
Shipping::extend('acs', fn ($app) => $app->make(AcsRateDriver::class));
Shipping::extend('box-now', fn ($app) => $app->make(BoxNowRateDriver::class));
$this->app->make(ConsoleSchedule::class)
->job(new WarmAcsAreaCacheJob)
->dailyAt('06:00')
->when(fn () => ShippingMethod::where('driver', 'acs')->exists());
$this->app->make(ConsoleSchedule::class)
->job(new PollShipmentTrackingJob)
->everyThirtyMinutes();
$this->overrideRatesPageLivewireComponent();
});
}
/**
* The vendor Rates page has no extension hook, so we swap it for our
* subclass (see Shipping/Filament/Pages/ManageShippingRates). Filament
* already registered the vendor class as a Livewire component under a
* name derived from its class string (see
* Panel::registerLivewireComponents()); Livewire's own registry is a
* simple last-write-wins name => class map, so re-registering the same
* derived name against our subclass here overrides it — keeping the
* route, sub-navigation, and every Livewire round-trip (including form
* submissions) pointed at one consistent component identity.
*/
private function overrideRatesPageLivewireComponent(): void
{
$name = $this->app->make(ComponentRegistry::class)->getName(VendorManageShippingRates::class);
Livewire::component($name, ManageShippingRates::class);
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace Modules\Core\Recovery\Events;
use Lunar\Models\Cart;
/**
* A cart has gone stale (no activity for config('core.cart.abandoned_after'))
* with NO order ever started — the shopper added items and never began
* checkout. Weak purchase-intent signal: usually a browsing/price-check
* action, not a near-purchase. Distinct from CheckoutAbandoned, which fires
* for a cart that DID reach checkout (a draft Order exists) but never placed
* it — a much stronger intent signal, and reachable via the email/address
* checkout itself usually captures even for a guest.
*
* Lives under Recovery, not Cart — abandonment detection/tracking is
* deliberately kept out of the Cart module entirely, including its event
* definitions, so Cart has no abandonment-related code at all. See
* docs/cart.md and docs/recovery-strategies.md.
*
* "Abandoned" is a derived state (stale updated_at), not something that
* transitions via a normal Eloquent write, so there's no natural model-event
* hook to dispatch this from directly — detection is Recovery's own concern
* (not yet built; design notes in docs/recovery-strategies.md).
*/
class CartAbandoned
{
public function __construct(
public readonly Cart $cart,
) {}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace Modules\Core\Recovery\Events;
use Lunar\Models\Cart;
use Lunar\Models\Order;
/**
* A cart's checkout has gone stale (no activity for
* config('core.cart.abandoned_after')) with a draft Order already created
* (Order::isDraft() — placed_at IS NULL) but never placed. Strong
* purchase-intent signal — the shopper committed to checking out, something
* blocked completion. Distinct from CartAbandoned, which fires for a cart
* with no order at all (weak intent, usually unreachable). Checkout
* typically captures an email/address even for a guest, so this state is
* normally reachable regardless of login status.
*
* Lives under Recovery, not Checkout/Cart — abandonment detection/tracking
* is deliberately kept out of both modules entirely, including its event
* definitions. See docs/cart.md and docs/recovery-strategies.md.
*
* "Abandoned" is a derived state (stale updated_at, no placed_at), not
* something that transitions via a normal Eloquent write — detection is
* Recovery's own concern (not yet built; design notes in
* docs/recovery-strategies.md).
*/
class CheckoutAbandoned
{
public function __construct(
public readonly Cart $cart,
public readonly Order $order,
) {}
}
+11
View File
@@ -0,0 +1,11 @@
<?php
namespace Modules\Core\Shipping\Carriers\Acs;
class AcsArea
{
public function __construct(
public readonly string $stationId,
public readonly int $branchId,
) {}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace Modules\Core\Shipping\Carriers\Acs;
use Illuminate\Support\Facades\Http;
class AcsClient
{
public function __construct(private readonly array $config) {}
public function call(string $alias, array $parameters = []): AcsResponse
{
$response = Http::withHeaders([
'AcsApiKey' => $this->config['api_key'],
])
->timeout($this->config['timeout'])
->post($this->config['base_url'], [
'ACSAlias' => $alias,
'ACSInputParameters' => array_merge($this->credentialParams(), $parameters),
]);
return AcsResponse::fromHttpResponse($response);
}
private function credentialParams(): array
{
return [
'Company_ID' => $this->config['company_id'],
'Company_Password' => $this->config['company_password'],
'User_ID' => $this->config['user_id'],
'User_Password' => $this->config['user_password'],
];
}
}
@@ -0,0 +1,202 @@
<?php
namespace Modules\Core\Shipping\Carriers\Acs;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Lunar\Models\Order;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\Contracts\SupportsManifestBatching;
use Modules\Core\Shipping\Contracts\SupportsTracking;
use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException;
use Modules\Core\Shipping\DataTransferObjects\ManifestResult;
use Modules\Core\Shipping\DataTransferObjects\ShipmentRequest;
use Modules\Core\Shipping\DataTransferObjects\TrackingCheckpoint;
use Modules\Core\Shipping\Enums\TrackingStatus;
use Modules\Core\Shipping\Models\Shipment;
class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsManifestBatching, SupportsTracking
{
public function __construct(
private readonly AcsClient $client,
private readonly AreaResolver $areaResolver,
) {}
public function createShipment(Order $order, ShipmentRequest $request): Shipment
{
$address = $order->shippingAddress;
$destination = $this->areaResolver->resolve($address->postcode);
$weight = $request->weight ?? 0.5;
$params = [
'Pickup_Date' => now()->toDateString(),
'Sender' => config('acs.sender.name'),
'Recipient_Name' => trim("{$address->first_name} {$address->last_name}"),
'Recipient_Address' => $address->line_one,
'Recipient_Zipcode' => $address->postcode,
'Recipient_Region' => $address->city,
'Recipient_Phone' => $address->contact_phone,
'Recipient_Country' => 'GR',
'Acs_Station_Branch_Destination' => $destination->branchId,
'Billing_Code' => config('acs.billing_code'),
'Charge_Type' => 2,
'Item_Quantity' => $request->packageCount,
'Weight' => $weight,
];
if ($request->paymentMode === 'cod') {
$params['Cod_Ammount'] = $request->amountToCollect ?? $order->total->decimal;
$params['Cod_Payment_Way'] = 0; // cash
$params['Acs_Delivery_Products'] = 'COD';
}
$response = $this->client->call('ACS_Create_Voucher', $params)->throwIfError();
$voucherNo = (string) $response->valueOutput['Voucher_No'];
$shipment = Shipment::create([
'order_id' => $order->id,
'carrier' => 'acs',
'tracking_reference' => $voucherNo,
'meta' => [
'station_destination' => $destination->stationId,
'weight' => $weight,
'pickup_date' => now()->toDateString(),
],
]);
if ($request->packageCount > 1) {
$this->persistMultipartVouchers($shipment);
}
return $shipment;
}
public function printLabel(Shipment $shipment): string
{
$response = $this->client->call('ACS_Print_Voucher', [
'Voucher_No' => $shipment->tracking_reference,
'Print_Type' => 2,
'Start_Position' => 1,
])->throwIfError();
$shipment->update(['label_printed_at' => now()]);
return $response->valueOutput[$shipment->tracking_reference] ?? '';
}
public function cancelShipment(Shipment $shipment): void
{
if ($shipment->manifest_reference) {
throw new \RuntimeException('Cannot cancel a shipment already included in an issued manifest.');
}
$this->client->call('ACS_Delete_Voucher', [
'Voucher_No' => $shipment->tracking_reference,
])->throwIfError();
$shipment->update(['cancelled_at' => now()]);
}
public function pendingForManifest(): Collection
{
return Shipment::query()
->where('carrier', 'acs')
->whereNull('manifest_reference')
->whereNull('cancelled_at')
->get();
}
public function issueManifest(Collection $shipments): ManifestResult
{
$unprinted = $shipments->whereNull('label_printed_at');
if ($unprinted->isNotEmpty()) {
return ManifestResult::blocked($unprinted, 'unprinted');
}
$response = $this->client->call('ACS_Issue_Pickup_List', [
'Pickup_Date' => now()->toDateString(),
'MyData' => null,
])->throwIfError();
$pickupListNo = (string) $response->valueOutput['PickupList_No'];
$shipments->each(fn (Shipment $shipment) => $shipment->update([
'manifest_reference' => $pickupListNo,
]));
return ManifestResult::success($pickupListNo, $shipments);
}
public function trackShipment(Shipment $shipment): Collection
{
$response = $this->client->call('ACS_TrackingDetails', [
'Voucher_No' => $shipment->tracking_reference,
])->throwIfError();
$rows = $response->tableOutput['Table_Data'] ?? [];
// ACS's per-checkpoint data (checkpoint_action) is free text with no
// status code, so the final checkpoint's status is corroborated
// against the structured summary call rather than guessed from text.
$isDelivered = $this->isDelivered($shipment);
return collect($rows)->values()->map(function (array $row, int $index) use ($rows, $isDelivered) {
$isLast = $index === count($rows) - 1;
return new TrackingCheckpoint(
status: $isLast && $isDelivered
? TrackingStatus::Delivered
: $this->guessStatusFromAction($row['checkpoint_action'] ?? ''),
carrierStatus: $row['checkpoint_action'] ?? null,
message: $row['checkpoint_action'] ?? null,
location: $row['checkpoint_location'] ?? null,
occurredAt: Carbon::parse($row['checkpoint_date_time']),
meta: $row,
);
});
}
private function isDelivered(Shipment $shipment): bool
{
try {
$response = $this->client->call('ACS_Trackingsummary', [
'Voucher_No' => $shipment->tracking_reference,
])->throwIfError();
} catch (AcsApiException) {
return false;
}
return (int) ($response->valueOutput['shipment_status'] ?? 0) === 4;
}
private function guessStatusFromAction(string $action): TrackingStatus
{
$action = strtolower($action);
return match (true) {
str_contains($action, 'delivery to consignee') => TrackingStatus::Delivered,
str_contains($action, 'on delivery') => TrackingStatus::OutForDelivery,
str_contains($action, 'arrival') || str_contains($action, 'departure') => TrackingStatus::InTransit,
default => TrackingStatus::Pending,
};
}
private function persistMultipartVouchers(Shipment $mainShipment): void
{
$response = $this->client->call('ACS_Get_Multipart_Vouchers', [
'Main_Voucher_No' => $mainShipment->tracking_reference,
])->throwIfError();
foreach ($response->tableOutput['Table_Data'] ?? [] as $row) {
Shipment::create([
'order_id' => $mainShipment->order_id,
'carrier' => 'acs',
'tracking_reference' => $row['MultiPart_Voucher_No'],
'parent_reference' => $mainShipment->tracking_reference,
'meta' => $mainShipment->meta?->toArray() ?? [],
]);
}
}
}
+116
View File
@@ -0,0 +1,116 @@
<?php
namespace Modules\Core\Shipping\Carriers\Acs;
use Lunar\DataTypes\Price;
use Lunar\DataTypes\ShippingOption;
use Lunar\Shipping\DataTransferObjects\ShippingOptionRequest;
use Lunar\Shipping\Interfaces\ShippingRateInterface;
use Lunar\Shipping\Models\ShippingRate;
use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException;
use Modules\Core\Shipping\Concerns\ResolvesFixedPricing;
use Modules\Core\Shipping\Contracts\SupportsLivePricing;
class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing
{
use ResolvesFixedPricing;
public ShippingRate $shippingRate;
public function __construct(
private readonly AcsClient $client,
private readonly AreaResolver $areaResolver,
) {}
public function name(): string
{
return 'ACS Courier';
}
public function description(): string
{
return 'Live rate quote from ACS Courier.';
}
public function resolve(ShippingOptionRequest $shippingOptionRequest): ?ShippingOption
{
$shippingRate = $shippingOptionRequest->shippingRate;
$shippingMethod = $shippingRate->shippingMethod;
$cart = $shippingOptionRequest->cart;
if (($shippingMethod->data['charge_by'] ?? 'cart_total') !== 'live') {
return $this->resolveFixedPrice($shippingRate, $shippingMethod, $cart);
}
$postcode = $cart->shippingAddress?->postcode;
if (! $postcode) {
return null;
}
return $this->resolveLivePrice($shippingRate, $shippingMethod, $cart, $postcode);
}
private function resolveLivePrice(ShippingRate $shippingRate, $shippingMethod, $cart, string $postcode): ?ShippingOption
{
try {
$destination = $this->areaResolver->resolve($postcode);
$response = $this->client->call('ACS_Price_Calculation', [
'Billing_Code' => config('acs.billing_code'),
'Acs_Station_Destination' => $destination->stationId,
'Weight' => $this->totalWeightInKg($cart),
'Pickup_Date' => now()->toDateString(),
'Charge_Type' => 2,
])->throwIfError();
} catch (AcsApiException $e) {
report($e);
return null;
}
$amount = (int) round(($response->valueOutput['Total_Ammount'] ?? 0) * 100);
return new ShippingOption(
name: $shippingMethod->name ?: $this->name(),
description: $shippingMethod->description ?: $this->description(),
identifier: $shippingRate->getIdentifier(),
price: new Price($amount, $cart->currency, 1),
taxClass: $shippingRate->getTaxClass(),
taxReference: $shippingRate->getTaxReference(),
meta: ['acs_station_destination' => $destination->stationId],
);
}
public function on(ShippingRate $shippingRate): self
{
$this->shippingRate = $shippingRate;
return $this;
}
private function totalWeightInKg($cart): float
{
$weight = 0.0;
foreach ($cart->lines->load('purchasable') as $line) {
$variant = $line->purchasable;
if (! $variant || ! $variant->weight_value) {
continue;
}
$unit = $variant->weight_unit ?? 'kg';
$value = (float) $variant->weight_value;
$weight += match ($unit) {
'g' => $value / 1000,
'lb' => $value * 0.45359237,
'oz' => $value * 0.0283495231,
default => $value, // kg
} * $line->quantity;
}
return max($weight, 0.5); // ACS minimum billable weight
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace Modules\Core\Shipping\Carriers\Acs;
use Illuminate\Http\Client\Response;
use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException;
class AcsResponse
{
private function __construct(
public readonly bool $hasError,
public readonly ?string $errorMessage,
public readonly array $valueOutput,
public readonly array $tableOutput,
) {}
public static function fromHttpResponse(Response $response): self
{
$body = $response->json() ?? [];
// ACS's own JSON key is misspelled ("Responce") — preserved here verbatim.
$output = $body['ACSOutputResponce'] ?? [];
return new self(
hasError: (bool) ($body['ACSExecution_HasError'] ?? ! $response->successful()),
errorMessage: $body['ACSExecutionErrorMessage'] ?? null,
valueOutput: $output['ACSValueOutput'][0] ?? [],
tableOutput: $output['ACSTableOutput'] ?? [],
);
}
public function throwIfError(): self
{
if ($this->hasError) {
throw new AcsApiException(
$this->errorMessage ?? ($this->valueOutput['Error_Message'] ?? 'Unknown ACS API error'),
$this->tableOutput,
);
}
return $this;
}
}
@@ -0,0 +1,81 @@
<?php
namespace Modules\Core\Shipping\Carriers\Acs;
use Illuminate\Support\Facades\Cache;
use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException;
class AreaResolver
{
public const CACHE_KEY = 'acs.areas';
public function __construct(private readonly AcsClient $client) {}
/**
* Resolve a Greek postcode to its ACS station/branch codes.
*
* Reads from the table warmed daily by WarmAcsAreaCacheJob. Falls
* back to a live lookup for that single postcode if the warmed cache is
* missing (e.g. the daily job never ran or failed) or doesn't contain it.
*/
public function resolve(string $postcode): AcsArea
{
$areas = Cache::get(self::CACHE_KEY);
if ($areas !== null && isset($areas[$postcode])) {
return $this->toArea($areas[$postcode]);
}
return $this->toArea($this->fetch($postcode));
}
/**
* Fetch and cache the full country's postcode-to-station map in one call.
*/
public function warmAll(): void
{
$areas = [];
foreach ($this->fetchAll() as $row) {
$areas[$row['Zip_Code']] = $row;
}
Cache::forever(self::CACHE_KEY, $areas);
}
private function fetch(string $postcode): array
{
$response = $this->client->call('ACS_Area_Find_By_Zip_Code', [
'Zip_Code' => $postcode,
'Show_Only_Inaccessible_Areas' => 0,
'Country' => 'GR',
])->throwIfError();
$area = $response->tableOutput['Table_Data'][0] ?? null;
if (! $area) {
throw new AcsApiException("No ACS area found for postcode {$postcode}");
}
return $area;
}
private function fetchAll(): array
{
$response = $this->client->call('ACS_Area_Find_By_Zip_Code', [
'Zip_Code' => null,
'Show_Only_Inaccessible_Areas' => 0,
'Country' => 'GR',
])->throwIfError();
return $response->tableOutput['Table_Data'] ?? [];
}
private function toArea(array $row): AcsArea
{
return new AcsArea(
stationId: $row['Station_ID'],
branchId: (int) $row['Branch_ID'],
);
}
}
@@ -0,0 +1,13 @@
<?php
namespace Modules\Core\Shipping\Carriers\Acs\Exceptions;
use RuntimeException;
class AcsApiException extends RuntimeException
{
public function __construct(string $message, public readonly array $tableOutput = [])
{
parent::__construct($message);
}
}
@@ -0,0 +1,27 @@
<?php
namespace Modules\Core\Shipping\Carriers\Acs\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Modules\Core\Shipping\Carriers\Acs\AreaResolver;
class WarmAcsAreaCacheJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public int $tries = 3;
public int $backoff = 60;
public function handle(AreaResolver $areaResolver): void
{
$areaResolver->warmAll();
}
}
@@ -0,0 +1,97 @@
<?php
namespace Modules\Core\Shipping\Carriers\BoxNow;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Modules\Core\Shipping\Carriers\BoxNow\Exceptions\BoxNowApiException;
class BoxNowClient
{
private const TOKEN_CACHE_KEY = 'boxnow.access_token';
public function __construct(private readonly array $config) {}
/**
* GET/POST against the main delivery/parcel API, authenticated with a
* cached Bearer token.
*/
public function request(string $method, string $path, array $payload = []): array
{
$response = Http::withToken($this->token())
->timeout($this->config['timeout'])
->{$method}("{$this->config['base_url']}{$path}", $payload);
if ($response->status() === 401) {
// Token expired early / was revoked — refresh once and retry.
Cache::forget(self::TOKEN_CACHE_KEY);
$response = Http::withToken($this->token())
->timeout($this->config['timeout'])
->{$method}("{$this->config['base_url']}{$path}", $payload);
}
if ($response->failed()) {
throw new BoxNowApiException(
"Box Now API error ({$response->status()}) on {$method} {$path}",
$response->json() ?? [],
);
}
return $response->json() ?? [];
}
/**
* The origins/destinations lookups are served faster from a separate
* location API host, per Box Now's own documentation.
*/
public function locationRequest(string $path, array $query = []): array
{
$response = Http::withToken($this->token())
->timeout($this->config['timeout'])
->get("{$this->config['location_api_url']}{$path}", $query);
if ($response->failed()) {
throw new BoxNowApiException(
"Box Now location API error ({$response->status()}) on GET {$path}",
$response->json() ?? [],
);
}
return $response->json() ?? [];
}
/**
* Fetch raw bytes (e.g. a PDF label) rather than JSON.
*/
public function requestRaw(string $path): string
{
$response = Http::withToken($this->token())
->timeout($this->config['timeout'])
->get("{$this->config['base_url']}{$path}");
if ($response->failed()) {
throw new BoxNowApiException("Box Now API error ({$response->status()}) on GET {$path}");
}
return $response->body();
}
private function token(): string
{
return Cache::remember(self::TOKEN_CACHE_KEY, now()->addMinutes(55), function () {
$response = Http::timeout($this->config['timeout'])
->post("{$this->config['base_url']}/auth-sessions", [
'grant_type' => 'client_credentials',
'client_id' => $this->config['client_id'],
'client_secret' => $this->config['client_secret'],
]);
if ($response->failed()) {
throw new BoxNowApiException('Box Now authentication failed', $response->json() ?? []);
}
return $response->json('access_token');
});
}
}
@@ -0,0 +1,150 @@
<?php
namespace Modules\Core\Shipping\Carriers\BoxNow;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Lunar\Models\Order;
use Modules\Core\Shipping\Carriers\BoxNow\Exceptions\BoxNowApiException;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\Contracts\SupportsTracking;
use Modules\Core\Shipping\DataTransferObjects\ShipmentRequest;
use Modules\Core\Shipping\DataTransferObjects\TrackingCheckpoint;
use Modules\Core\Shipping\Enums\TrackingStatus;
use Modules\Core\Shipping\Models\Shipment;
/**
* Unlike ACS, Box Now has no manifest/pickup-list step — creating a
* delivery request also books the courier pickup, so this only implements
* CarrierFulfillmentInterface (not SupportsManifestBatching).
*
* Box Now delivers to lockers, not addresses. The storefront locker-picker
* is out of scope for this pass — createShipment() requires the chosen
* locker's Box Now locationId via ShipmentRequest::$destinationLocationId
* (e.g. set manually by admin staff until checkout UI exists).
*/
class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsTracking
{
public function __construct(private readonly BoxNowClient $client) {}
public function createShipment(Order $order, ShipmentRequest $request): Shipment
{
$address = $order->shippingAddress;
$destinationLocationId = $request->destinationLocationId;
if (! $destinationLocationId) {
throw new BoxNowApiException('No Box Now locker (locationId) was provided for this shipment.');
}
$isCod = $request->paymentMode === 'cod';
$response = $this->client->request('post', '/delivery-requests', [
'orderNumber' => $order->reference.'-'.$order->id,
'invoiceValue' => number_format($order->total->decimal, 2, '.', ''),
'paymentMode' => $isCod ? 'cod' : 'prepaid',
'amountToBeCollected' => $isCod
? number_format($request->amountToCollect ?? $order->total->decimal, 2, '.', '')
: '0.00',
'origin' => [
'contactNumber' => config('boxnow.sender.phone'),
'contactEmail' => config('boxnow.sender.email'),
'contactName' => config('boxnow.sender.name'),
'locationId' => config('boxnow.origin_location_id'),
],
'destination' => [
'contactNumber' => $address->contact_phone,
'contactEmail' => $address->contact_email,
'contactName' => trim("{$address->first_name} {$address->last_name}"),
'locationId' => $destinationLocationId,
],
'items' => [
[
'id' => (string) $order->id,
'name' => 'Order '.$order->reference,
'value' => '0.00',
'compartmentSize' => 1,
'weight' => $request->weight ?? 0,
],
],
]);
$parcelId = (string) ($response['parcels'][0]['id'] ?? throw new BoxNowApiException(
'Box Now delivery request succeeded but returned no parcel id.',
$response,
));
return Shipment::create([
'order_id' => $order->id,
'carrier' => 'box-now',
'tracking_reference' => $parcelId,
'meta' => [
'delivery_request_id' => $response['id'] ?? null,
'locker_id' => $destinationLocationId,
],
]);
}
public function printLabel(Shipment $shipment): string
{
$bytes = $this->client->requestRaw("/parcels/{$shipment->tracking_reference}/label.pdf");
$shipment->update(['label_printed_at' => now()]);
return $bytes;
}
public function cancelShipment(Shipment $shipment): void
{
$this->client->request('post', "/parcels/{$shipment->tracking_reference}:cancel");
$shipment->update(['cancelled_at' => now()]);
}
public function trackShipment(Shipment $shipment): Collection
{
$response = $this->client->request('get', '/parcels', [
'parcelId' => $shipment->tracking_reference,
]);
$parcel = $response['data'][0] ?? null;
if (! $parcel) {
return collect();
}
$events = $parcel['events'] ?? [];
// Fall back to a single checkpoint from the parcel's current state
// if Box Now didn't return a detailed events history.
if (empty($events)) {
$events = [[
'type' => $parcel['state'] ?? 'new',
'locationDisplayName' => null,
'createTime' => $parcel['updateTime'] ?? $parcel['createTime'] ?? now()->toIso8601String(),
]];
}
return collect($events)->map(fn (array $event) => new TrackingCheckpoint(
status: $this->mapState($event['type'] ?? $parcel['state'] ?? 'new'),
carrierStatus: $event['type'] ?? $parcel['state'] ?? null,
message: null,
location: $event['locationDisplayName'] ?? null,
occurredAt: Carbon::parse($event['createTime']),
meta: $event,
));
}
private function mapState(string $state): TrackingStatus
{
return match ($state) {
'new' => TrackingStatus::Pending,
'in-transit', 'in-depot' => TrackingStatus::InTransit,
'in-final-destination', 'wait-for-load' => TrackingStatus::OutForDelivery,
'delivered' => TrackingStatus::Delivered,
'returned', 'accepted-for-return' => TrackingStatus::Returned,
'cancelled' => TrackingStatus::Cancelled,
'expired-return', 'missing' => TrackingStatus::Failed,
default => TrackingStatus::Unknown,
};
}
}
@@ -0,0 +1,48 @@
<?php
namespace Modules\Core\Shipping\Carriers\BoxNow;
use Lunar\DataTypes\ShippingOption;
use Lunar\Shipping\DataTransferObjects\ShippingOptionRequest;
use Lunar\Shipping\Interfaces\ShippingRateInterface;
use Lunar\Shipping\Models\ShippingRate;
use Modules\Core\Shipping\Concerns\ResolvesFixedPricing;
/**
* Box Now has no pricing API, so this always resolves the method's normal
* charge_by + price-break configuration — the same mechanism the built-in
* flat-rate/ship-by drivers use. Does not implement SupportsLivePricing:
* there is no live option to offer.
*/
class BoxNowRateDriver implements ShippingRateInterface
{
use ResolvesFixedPricing;
public ShippingRate $shippingRate;
public function name(): string
{
return 'Box Now Locker Delivery';
}
public function description(): string
{
return 'Deliver to a Box Now parcel locker.';
}
public function resolve(ShippingOptionRequest $shippingOptionRequest): ?ShippingOption
{
return $this->resolveFixedPrice(
$shippingOptionRequest->shippingRate,
$shippingOptionRequest->shippingRate->shippingMethod,
$shippingOptionRequest->cart,
);
}
public function on(ShippingRate $shippingRate): self
{
$this->shippingRate = $shippingRate;
return $this;
}
}
@@ -0,0 +1,13 @@
<?php
namespace Modules\Core\Shipping\Carriers\BoxNow\Exceptions;
use RuntimeException;
class BoxNowApiException extends RuntimeException
{
public function __construct(string $message, public readonly array $body = [])
{
parent::__construct($message);
}
}
@@ -0,0 +1,42 @@
<?php
namespace Modules\Core\Shipping\Concerns;
use Lunar\DataTypes\ShippingOption;
use Lunar\Facades\Pricing;
use Lunar\Shipping\Models\ShippingMethod;
use Lunar\Shipping\Models\ShippingRate;
/**
* Shared by any carrier driver that also supports Lunar's own price-break
* pricing (charge_by = cart_total | weight) as a fallback to, or standalone
* alternative for, live API pricing. Mirrors the vendor ShipBy driver's
* charge_by handling exactly, so behavior is consistent with the rest of
* Lunar's shipping system rather than inventing a separate mechanism.
*/
trait ResolvesFixedPricing
{
private function resolveFixedPrice(ShippingRate $shippingRate, ShippingMethod $shippingMethod, $cart): ?ShippingOption
{
$chargeBy = $shippingMethod->data['charge_by'] ?? 'cart_total';
$tier = $chargeBy === 'weight'
? $cart->lines->load('purchasable')->sum(fn ($line) => ($line->purchasable->weight_value ?? 0) * $line->quantity)
: $cart->lines->sum('subTotal.value');
$pricing = Pricing::for($shippingRate)->qty($tier)->get();
if (! $pricing->matched) {
return null;
}
return new ShippingOption(
name: $shippingMethod->name ?: $this->name(),
description: $shippingMethod->description ?: $this->description(),
identifier: $shippingRate->getIdentifier(),
price: $pricing->matched->price,
taxClass: $shippingRate->getTaxClass(),
taxReference: $shippingRate->getTaxReference(),
);
}
}
@@ -0,0 +1,25 @@
<?php
namespace Modules\Core\Shipping\Contracts;
use Lunar\Models\Order;
use Modules\Core\Shipping\DataTransferObjects\ShipmentRequest;
use Modules\Core\Shipping\Models\Shipment;
interface CarrierFulfillmentInterface
{
/**
* Create a shipment with the carrier for the given order.
*/
public function createShipment(Order $order, ShipmentRequest $request): Shipment;
/**
* Fetch the printable label for a shipment (raw file bytes).
*/
public function printLabel(Shipment $shipment): string;
/**
* Cancel a shipment with the carrier.
*/
public function cancelShipment(Shipment $shipment): void;
}
@@ -0,0 +1,12 @@
<?php
namespace Modules\Core\Shipping\Contracts;
/**
* Marker for a Lunar\Shipping\Interfaces\ShippingRateInterface driver that
* calculates its price from a live carrier API rather than the manually
* configured ShippingRate price/price-breaks. Admin UI uses this to hide
* the manual pricing fields for such drivers — carriers without a pricing
* API simply don't implement it, and manual pricing remains required.
*/
interface SupportsLivePricing {}
@@ -0,0 +1,24 @@
<?php
namespace Modules\Core\Shipping\Contracts;
use Illuminate\Support\Collection;
use Modules\Core\Shipping\DataTransferObjects\ManifestResult;
/**
* Optional capability for carriers that batch shipments into a manifest
* before courier pickup (e.g. ACS's end-of-day pickup list). Carriers
* without this concept simply don't implement it.
*/
interface SupportsManifestBatching
{
/**
* Shipments created but not yet included in an issued manifest.
*/
public function pendingForManifest(): Collection;
/**
* Finalize the given shipments into a manifest with the carrier.
*/
public function issueManifest(Collection $shipments): ManifestResult;
}
@@ -0,0 +1,23 @@
<?php
namespace Modules\Core\Shipping\Contracts;
use Illuminate\Support\Collection;
use Modules\Core\Shipping\DataTransferObjects\TrackingCheckpoint;
use Modules\Core\Shipping\Models\Shipment;
/**
* Optional capability for carriers that expose shipment tracking. Carriers
* without a tracking API simply don't implement it.
*/
interface SupportsTracking
{
/**
* Return the shipment's known checkpoints from the carrier — as many
* as the carrier's API returns in one call, not just the latest one.
* Deduplication against what's already stored happens elsewhere.
*
* @return Collection<int, TrackingCheckpoint>
*/
public function trackShipment(Shipment $shipment): Collection;
}
@@ -0,0 +1,26 @@
<?php
namespace Modules\Core\Shipping\DataTransferObjects;
use Illuminate\Support\Collection;
class ManifestResult
{
private function __construct(
public readonly bool $success,
public readonly ?string $reference,
public readonly Collection $includedShipments,
public readonly Collection $blockedShipments,
public readonly ?string $reason,
) {}
public static function success(string $reference, Collection $includedShipments): self
{
return new self(true, $reference, $includedShipments, collect(), null);
}
public static function blocked(Collection $blockedShipments, string $reason): self
{
return new self(false, null, collect(), $blockedShipments, $reason);
}
}
@@ -0,0 +1,20 @@
<?php
namespace Modules\Core\Shipping\DataTransferObjects;
/**
* Carrier-agnostic input for CarrierFulfillmentInterface::createShipment().
* Every field is optional — a carrier reads only what it needs and ignores
* the rest (e.g. destinationLocationId only matters to locker-delivery
* carriers like Box Now; ACS has no use for it).
*/
class ShipmentRequest
{
public function __construct(
public readonly ?float $weight = null,
public readonly int $packageCount = 1,
public readonly ?string $destinationLocationId = null,
public readonly ?string $paymentMode = null,
public readonly ?float $amountToCollect = null,
) {}
}
@@ -0,0 +1,22 @@
<?php
namespace Modules\Core\Shipping\DataTransferObjects;
use Carbon\CarbonInterface;
use Modules\Core\Shipping\Enums\TrackingStatus;
/**
* One normalized checkpoint in a shipment's carrier-reported history.
* Returned (potentially several at once) by SupportsTracking::trackShipment().
*/
class TrackingCheckpoint
{
public function __construct(
public readonly TrackingStatus $status,
public readonly ?string $carrierStatus,
public readonly ?string $message,
public readonly ?string $location,
public readonly CarbonInterface $occurredAt,
public readonly array $meta = [],
) {}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace Modules\Core\Shipping\Enums;
/**
* Normalized shipment status, mapped from each carrier's own status codes
* (e.g. ACS's shipment_status/non_delivery_reason_code, Box Now's parcel
* state) so the rest of the system never needs to know carrier-specific
* vocabulary.
*/
enum TrackingStatus: string
{
case Pending = 'pending';
case InTransit = 'in_transit';
case OutForDelivery = 'out_for_delivery';
case Delivered = 'delivered';
case Failed = 'failed';
case Returned = 'returned';
case Cancelled = 'cancelled';
case Unknown = 'unknown';
public function isTerminal(): bool
{
return match ($this) {
self::Delivered, self::Returned, self::Cancelled => true,
default => false,
};
}
}
@@ -0,0 +1,17 @@
<?php
namespace Modules\Core\Shipping\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Modules\Core\Shipping\Models\ShipmentInfo;
/**
* Fired once per newly-recorded ShipmentInfo checkpoint. Listeners (e.g.
* customer notifications, order status sync) are added separately.
*/
class ShipmentStatusUpdatedByCarrier
{
use Dispatchable;
public function __construct(public readonly ShipmentInfo $shipmentInfo) {}
}
@@ -0,0 +1,116 @@
<?php
namespace Modules\Core\Shipping\Extensions;
use Filament\Actions;
use Filament\Forms;
use Filament\Notifications\Notification;
use Lunar\Admin\Support\Extending\ViewPageExtension;
use Lunar\Models\Order;
use Lunar\Shipping\Models\ShippingMethod;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\DataTransferObjects\ShipmentRequest;
class OrderViewExtension extends ViewPageExtension
{
public function headerActions(array $actions): array
{
$actions[] = $this->createShipmentAction();
return $actions;
}
private function createShipmentAction(): Actions\Action
{
return Actions\Action::make('create_shipment')
->label('Create Shipment')
->icon('heroicon-o-truck')
->modalSubmitActionLabel('Create Shipment')
->form([
Forms\Components\TextInput::make('weight')
->label('Package weight (kg)')
->numeric()
->minValue(0)
->helperText('Leave blank to use the carrier\'s default.'),
Forms\Components\TextInput::make('destination_location_id')
->label('Box Now locker ID')
->helperText('Only required for Box Now shipments.')
->default(fn (Order $record) => $record->shippingAddress?->meta['box_now_locker']['locationId'] ?? null),
Forms\Components\Toggle::make('confirm')
->label('Confirm')
->helperText('This will create a real shipment with the carrier.')
->rules([
function () {
return function (string $attribute, $value, \Closure $fail) {
if ($value !== true) {
$fail('Please confirm before creating the shipment.');
}
};
},
]),
])
->action(function (Order $record, array $data, Actions\Action $action) {
$service = $this->resolveFulfillmentService($record);
if (! $service) {
Notification::make()
->title('No carrier fulfillment integration is configured for this order.')
->danger()
->send();
$action->halt();
return;
}
$request = new ShipmentRequest(
weight: filled($data['weight'] ?? null) ? (float) $data['weight'] : null,
destinationLocationId: $data['destination_location_id'] ?? null,
);
try {
$service->createShipment($record, $request);
} catch (\Throwable $e) {
report($e);
Notification::make()
->title('Failed to create shipment: '.$e->getMessage())
->danger()
->send();
$action->halt();
return;
}
Notification::make()
->title('Shipment created.')
->success()
->send();
})
->visible(fn (Order $record) => $record->shipments()->exists() === false
&& $this->resolveFulfillmentService($record) !== null);
}
private function resolveCarrier(Order $record): ?string
{
$code = $record->shippingAddress?->shipping_option;
if (! $code) {
return null;
}
return ShippingMethod::where('code', $code)->value('driver');
}
private function resolveFulfillmentService(Order $record): ?CarrierFulfillmentInterface
{
$carrier = $this->resolveCarrier($record);
if (! $carrier) {
return null;
}
return app(CarrierFulfillmentInterface::class, ['carrier' => $carrier]);
}
}
@@ -0,0 +1,48 @@
<?php
namespace Modules\Core\Shipping\Extensions;
use Filament\Actions;
use Filament\Forms\Components\Group;
use Filament\Forms\Components\Select;
use Lunar\Admin\Support\Extending\BaseExtension;
use Lunar\Shipping\Facades\Shipping;
use Lunar\Shipping\Filament\Resources\ShippingMethodResource;
/**
* ListShippingMethod::getDefaultHeaderActions() builds its CreateAction's
* form inline (calling ShippingMethodResource::getDriverFormComponent()
* directly, a hardcoded 2-option Select) rather than through the resource's
* own extendForm() pipeline, so ShippingMethodResourceExtension's driver
* fix never reaches it. Re-declares the same create-action form with a
* dynamic driver Select instead.
*/
class ShippingMethodListExtension extends BaseExtension
{
public function headerActions(array $actions): array
{
foreach ($actions as $action) {
if ($action instanceof Actions\CreateAction) {
$action->form([
ShippingMethodResource::getNameFormComponent(),
Group::make([
ShippingMethodResource::getCodeFormComponent(),
$this->driverSelect(),
])->columns(2),
ShippingMethodResource::getDescriptionFormComponent(),
]);
}
}
return $actions;
}
private function driverSelect(): Select
{
return Select::make('driver')
->label('Type')
->options(fn () => collect(Shipping::getSupportedDrivers())
->mapWithKeys(fn ($driver, $key) => [$key => $driver->name()]))
->default('flat-rate');
}
}
@@ -0,0 +1,152 @@
<?php
namespace Modules\Core\Shipping\Extensions;
use Filament\Forms\Components\Component;
use Filament\Forms\Components\Concerns\HasChildComponents;
use Filament\Forms\Components\Select;
use Filament\Forms\Form;
use Filament\Forms\Get;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Lunar\Admin\Support\Extending\ResourceExtension;
use Lunar\Shipping\Facades\Shipping;
use Modules\Core\Shipping\Contracts\SupportsLivePricing;
class ShippingMethodResourceExtension extends ResourceExtension
{
public function extendForm(Form $form): Form
{
return $form->schema(
$this->replaceChargeByField(
$this->replaceDriverField($form->getComponents())
)
);
}
/**
* Extend the vendor's cart_total/weight charge_by Select with a third
* "live" option — only offered when the currently selected driver
* supports live pricing (see SupportsLivePricing). Picking it is what
* tells the driver to call its carrier API instead of resolving a
* price break.
*/
private function replaceChargeByField(array $components): array
{
return array_map(function (Component $component) {
if (method_exists($component, 'getName') && $component->getName() === 'charge_by') {
return $this->chargeBySelect();
}
if (in_array(HasChildComponents::class, class_uses_recursive($component), true)) {
$component->schema(
$this->replaceChargeByField($component->getChildComponents())
);
}
return $component;
}, $components);
}
private function chargeBySelect(): Select
{
return Select::make('charge_by')
->label('Charge by')
->options(function (Get $get) {
$options = [
'cart_total' => 'Cart Total',
'weight' => 'Weight',
];
// "charge_by" is nested inside a Group with
// ->statePath('data'), while "driver" sits one level up, at
// the form root. Note: an *absolute* path here would need to
// additionally account for the page's own form wrapper
// (EditRecord::getFormStatePath() === 'data'), which relative
// paths never cross — so "../driver" (relative) is the
// correct, page-independent way to reach it, not an
// absolute 'driver' string.
if ($this->driverSupportsLivePricing($get('../driver'))) {
$options['live'] = 'Live API pricing';
}
return $options;
})
->live();
}
private function driverSupportsLivePricing(?string $driver): bool
{
if (! $driver) {
return false;
}
try {
return Shipping::driver($driver) instanceof SupportsLivePricing;
} catch (\InvalidArgumentException) {
return false;
}
}
public function extendTable(Table $table): Table
{
return $table->columns(
array_map(function ($column) {
if (method_exists($column, 'getName') && $column->getName() === 'driver') {
return $this->driverColumn();
}
return $column;
}, $table->getColumns())
);
}
private function driverColumn(): TextColumn
{
return TextColumn::make('driver')
->label('Type')
->formatStateUsing(fn ($state) => $this->driverLabel($state));
}
private function driverLabel(string $key): string
{
$driver = collect(Shipping::getSupportedDrivers())->get($key);
return $driver?->name() ?? $key;
}
/**
* Recursively walk the form tree and replace the hardcoded driver
* Select (nested inside Section > Group) with one listing every
* registered driver, built-in or custom.
*
* @param array<Component> $components
* @return array<Component>
*/
private function replaceDriverField(array $components): array
{
return array_map(function (Component $component) {
if (method_exists($component, 'getName') && $component->getName() === 'driver') {
return $this->driverSelect();
}
if (in_array(HasChildComponents::class, class_uses_recursive($component), true)) {
$component->schema(
$this->replaceDriverField($component->getChildComponents())
);
}
return $component;
}, $components);
}
private function driverSelect(): Select
{
return Select::make('driver')
->label('Type')
->options(fn () => collect(Shipping::getSupportedDrivers())
->mapWithKeys(fn ($driver, $key) => [$key => $driver->name()]))
->default('flat-rate')
->live();
}
}
@@ -0,0 +1,106 @@
<?php
namespace Modules\Core\Shipping\Filament\Pages;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\BulkAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Lunar\Shipping\Facades\Shipping;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\Contracts\SupportsManifestBatching;
use Modules\Core\Shipping\Models\Shipment;
class ManagePickupManifests extends Page implements HasTable
{
use InteractsWithTable;
protected static ?string $navigationIcon = 'heroicon-o-truck';
protected static ?string $navigationLabel = 'Pickup Manifests';
protected static string $view = 'core::shipping.filament.pages.manage-pickup-manifests';
public function table(Table $table): Table
{
return $table
->query($this->pendingQuery())
->columns([
TextColumn::make('carrier')->badge(),
TextColumn::make('tracking_reference')->label('Tracking #'),
TextColumn::make('order.reference')->label('Order'),
TextColumn::make('label_printed_at')->label('Printed')->dateTime()->placeholder('Not printed'),
])
->actions([
Action::make('print')
->label('Print')
->icon('heroicon-o-printer')
->action(fn (Shipment $record) => $this->printShipment($record)),
])
->bulkActions([
BulkAction::make('print_selected')
->label('Print selected')
->icon('heroicon-o-printer')
->action(fn (Collection $records) => $records->each(fn (Shipment $shipment) => $this->printShipment($shipment))),
BulkAction::make('issue_manifest')
->label('Issue Manifest')
->icon('heroicon-o-check-circle')
->action(fn (Collection $records) => $this->issueManifest($records)),
]);
}
private function pendingQuery(): Builder
{
$carriers = collect(Shipping::getSupportedDrivers())->keys()->filter(
fn (string $carrier) => $this->fulfillmentService($carrier) instanceof SupportsManifestBatching
);
return Shipment::query()
->whereIn('carrier', $carriers)
->whereNull('manifest_reference')
->whereNull('cancelled_at');
}
private function printShipment(Shipment $shipment): void
{
$this->fulfillmentService($shipment->carrier)?->printLabel($shipment);
}
private function issueManifest(Collection $shipments): void
{
$shipments->groupBy('carrier')->each(function (Collection $group, string $carrier) {
$service = $this->fulfillmentService($carrier);
if (! $service instanceof SupportsManifestBatching) {
return;
}
$result = $service->issueManifest($group);
if (! $result->success) {
Notification::make()
->title("Manifest blocked for {$carrier}: {$result->reason}")
->danger()
->send();
return;
}
Notification::make()
->title("Manifest issued for {$carrier}: {$result->reference}")
->success()
->send();
});
}
private function fulfillmentService(string $carrier): ?CarrierFulfillmentInterface
{
return app(CarrierFulfillmentInterface::class, ['carrier' => $carrier]);
}
}
@@ -0,0 +1,104 @@
<?php
namespace Modules\Core\Shipping\Filament\Pages;
use Filament\Forms\Form;
use Filament\Forms\Get;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Lunar\Shipping\Filament\Resources\ShippingZoneResource\Pages\ManageShippingRates as BaseManageShippingRates;
use Lunar\Shipping\Models\ShippingMethod;
use Lunar\Shipping\Models\ShippingRate;
/**
* Bound in place of the vendor ManageShippingRates page via the container
* (see ShippingServiceProvider), since that page has no extension hook of
* its own. Every reference to the vendor class name — routes, sub-nav,
* ShippingZoneResource::getPages() — is untouched; the container simply
* hands back this subclass whenever the vendor class is resolved.
*
* Hides the price / price-break fields for a rate whose method has
* charge_by = "live" (see ShippingMethodResourceExtension, which adds that
* option to methods whose driver supports live pricing) — those fields
* would otherwise be dead configuration the driver never reads. Pricing
* strategy (cart_total / weight / live) stays entirely on the Shipping
* Method, matching Lunar's own existing charge_by convention; nothing new
* is stored on the rate itself.
*/
class ManageShippingRates extends BaseManageShippingRates
{
public function form(Form $form): Form
{
$form = parent::form($form);
return $form->schema(
$this->hidePriceFieldsWhenLive($form->getComponents())
);
}
private function hidePriceFieldsWhenLive(array $components): array
{
$isNotLive = fn (Get $get) => static::methodChargeBy($get('shipping_method_id')) !== 'live';
foreach ($components as $component) {
if (! method_exists($component, 'getName')) {
continue;
}
if ($component->getName() === 'price') {
$component->visible($isNotLive)->required($isNotLive)->dehydrated(true);
}
if ($component->getName() === 'prices') {
$component->visible($isNotLive)->dehydrated(true);
}
}
return $components;
}
public function table(Table $table): Table
{
$table = parent::table($table);
return $table->columns(
array_map(function ($column) {
if (method_exists($column, 'getName') && $column->getName() === 'basePrices.0') {
return TextColumn::make('basePrices.0')
->label(__('lunarpanel.shipping::relationmanagers.shipping_rates.table.price.label'))
->formatStateUsing(function ($state, ShippingRate $record) {
if (static::methodChargeBy($record->shipping_method_id) === 'live') {
return 'Live API pricing';
}
return $state?->price->formatted;
});
}
return $column;
}, $table->getColumns())
);
}
protected static function saveShippingRate(?ShippingRate $shippingRate = null, array $data = []): void
{
if (static::methodChargeBy($data['shipping_method_id'] ?? $shippingRate?->shipping_method_id) === 'live') {
return;
}
parent::saveShippingRate($shippingRate, $data);
}
protected static function methodChargeBy(ShippingMethod|int|string|null $method): ?string
{
if (blank($method)) {
return null;
}
if (! $method instanceof ShippingMethod) {
$method = ShippingMethod::find($method);
}
return $method?->data['charge_by'] ?? null;
}
}
@@ -0,0 +1,107 @@
<?php
namespace Modules\Core\Shipping\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Lunar\Shipping\Facades\Shipping;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\Contracts\SupportsTracking;
use Modules\Core\Shipping\Enums\TrackingStatus;
use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier;
use Modules\Core\Shipping\Models\Shipment;
use Modules\Core\Shipping\Models\ShipmentInfo;
/**
* Carrier-agnostic: polls every Shipment not yet in a terminal state,
* skipping carriers whose fulfillment service doesn't implement
* SupportsTracking. New checkpoints are recorded in shipment_info and
* dispatch ShipmentStatusUpdatedByCarrier — one event per new checkpoint.
*/
class PollShipmentTrackingJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public int $tries = 3;
public int $backoff = 60;
public function handle(): void
{
$trackableCarriers = collect(Shipping::getSupportedDrivers())->keys()->filter(
fn (string $carrier) => $this->fulfillmentService($carrier) instanceof SupportsTracking
);
if ($trackableCarriers->isEmpty()) {
return;
}
Shipment::query()
->whereIn('carrier', $trackableCarriers)
->whereNull('cancelled_at')
->whereDoesntHave('shipmentInfo', function ($query) {
$query->whereIn('status', [
TrackingStatus::Delivered->value,
TrackingStatus::Returned->value,
TrackingStatus::Cancelled->value,
]);
})
->chunkById(50, function ($shipments) {
$shipments->groupBy('carrier')->each(
fn ($group, $carrier) => $this->pollCarrierShipments($carrier, $group)
);
});
}
private function pollCarrierShipments(string $carrier, $shipments): void
{
$service = $this->fulfillmentService($carrier);
if (! $service instanceof SupportsTracking) {
return;
}
foreach ($shipments as $shipment) {
$this->recordNewCheckpoints($shipment, $service->trackShipment($shipment));
}
}
private function recordNewCheckpoints(Shipment $shipment, $checkpoints): void
{
$existing = $shipment->shipmentInfo()
->get(['status', 'occurred_at'])
->map(fn ($info) => $info->status->value.'|'.$info->occurred_at->toIso8601String())
->flip();
foreach ($checkpoints as $checkpoint) {
$fingerprint = $checkpoint->status->value.'|'.$checkpoint->occurredAt->toIso8601String();
if ($existing->has($fingerprint)) {
continue;
}
$info = ShipmentInfo::create([
'shipment_id' => $shipment->id,
'status' => $checkpoint->status,
'carrier_status' => $checkpoint->carrierStatus,
'message' => $checkpoint->message,
'location' => $checkpoint->location,
'occurred_at' => $checkpoint->occurredAt,
'meta' => $checkpoint->meta,
]);
ShipmentStatusUpdatedByCarrier::dispatch($info);
}
}
private function fulfillmentService(string $carrier): ?CarrierFulfillmentInterface
{
return app(CarrierFulfillmentInterface::class, ['carrier' => $carrier]);
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace Modules\Core\Shipping\Models;
use Illuminate\Database\Eloquent\Casts\AsArrayObject;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Lunar\Models\Order;
class Shipment extends Model
{
protected $guarded = [];
protected $casts = [
'meta' => AsArrayObject::class,
'label_printed_at' => 'datetime',
'cancelled_at' => 'datetime',
];
public function order(): BelongsTo
{
return $this->belongsTo(Order::class);
}
public function shipmentInfo(): HasMany
{
return $this->hasMany(ShipmentInfo::class);
}
public function latestShipmentInfo(): ?ShipmentInfo
{
return $this->shipmentInfo()->latest('occurred_at')->first();
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace Modules\Core\Shipping\Models;
use Illuminate\Database\Eloquent\Casts\AsArrayObject;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Modules\Core\Shipping\Enums\TrackingStatus;
/**
* One recorded checkpoint in a Shipment's carrier-reported tracking
* history. Append-only — never updated in place, so the full history is
* preserved rather than overwriting the last-known status.
*/
class ShipmentInfo extends Model
{
protected $table = 'shipment_info';
protected $guarded = [];
protected $casts = [
'status' => TrackingStatus::class,
'occurred_at' => 'datetime',
'meta' => AsArrayObject::class,
];
public function shipment(): BelongsTo
{
return $this->belongsTo(Shipment::class);
}
}