Changelog
This commit is contained in:
+201
@@ -4,6 +4,207 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||||
|
|
||||||
|
## [0.17.0] - Unreleased
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- `Modules\Core\Order\Notifications\OrderPlacedNotification` — an order confirmation email,
|
||||||
|
registered against `Modules\Core\Checkout\Events\OrderPlaced` (fires exactly once per order,
|
||||||
|
regardless of `capture_mode`/driver). Previously only a Stripe (auto-captured) order triggered
|
||||||
|
any placement email at all, via `OrderCapturedNotification` — a different concern (payment
|
||||||
|
confirmation) that happened to fire at the same moment for that one driver; an offline or
|
||||||
|
bank-transfer order got no confirmation whatsoever. Verified live via Mailpit.
|
||||||
|
- `Modules\Core\Order\Listeners\DecrementStockOnOrderPlaced` — also wired to `OrderPlaced`, the
|
||||||
|
first stock decrement anywhere in this codebase (previously nothing wrote to
|
||||||
|
`ProductVariant::stock` as a result of an order at all — overselling was possible). A single
|
||||||
|
atomic `UPDATE ... SET stock = GREATEST(stock - qty, 0)` per variant, not a read-then-write on
|
||||||
|
the Eloquent model, to avoid a lost-update race between two orders decrementing the same variant
|
||||||
|
concurrently. Only touches `purchasable === 'in_stock'` variants on `physical` order lines —
|
||||||
|
`always`/`backorder` variants are deliberately left alone (their stock has no purchasing
|
||||||
|
consequence, decrementing it would just make the column an inaccurate negative number). Also
|
||||||
|
re-triggers Scout reindexing for every affected product, closing the gap `Modules\Core\Catalog\
|
||||||
|
Services\ProductIndexer`'s own docblock flagged ("nothing currently reindexes a product when an
|
||||||
|
order decrements its stock") — the search index's `in_stock` filter now reflects the change
|
||||||
|
immediately rather than only on the next scheduled reindex.
|
||||||
|
- `Modules\Core\Cart\Services\CartLifecycleService` — the single source of truth for the four
|
||||||
|
cart lifecycle states (Ongoing, Abandoned Cart, Abandoned Checkout, Completed) documented in
|
||||||
|
`docs/cart.md`. Previously `Modules\Core\Cart\Filament\Resources\CartResource\Pages\ListCarts`
|
||||||
|
and `Modules\Core\Cart\Commands\DetectAbandonedCarts` each reimplemented the same query split
|
||||||
|
independently, which is exactly the kind of drift that lets the admin panel and the
|
||||||
|
recovery-email pipeline quietly disagree about what "abandoned" means. Both now build on the
|
||||||
|
same `ongoing()`/`abandonedCarts()`/`abandonedCheckouts()`/`completed()` methods, each taking a
|
||||||
|
`Builder` so callers compose the scope onto whatever base query they already have — Filament's
|
||||||
|
own tab query (search/sort/pagination intact) for `ListCarts`, a bare `Cart::query()` for the
|
||||||
|
command.
|
||||||
|
- `core.cart.unrecoverable_after` config (default `90 days`) — beyond this age, a stale cart
|
||||||
|
stops being treated as an active "Abandoned Cart"/"Abandoned Checkout" at all (excluded from
|
||||||
|
both `CartLifecycleService` methods), rather than staying flagged as an actionable abandonment
|
||||||
|
forever. A 90-day-old (or older) cart's pricing/stock/tax have very likely moved on, so it's not
|
||||||
|
a realistic recovery target — this is about the abandoned-cart pipeline only, not data
|
||||||
|
retention; no rows are deleted or pruned.
|
||||||
|
- `Modules\Core\Cart\Filament\Resources\CartResource\Pages\ViewCart`'s Lines section now shows
|
||||||
|
each line's product thumbnail, name (linking to the product's edit page), and variant options —
|
||||||
|
not just SKU/quantity/price — mirroring Lunar's own order line item display
|
||||||
|
(`OrderItemsTable`). Also added a new Shipping section: the resolved shipping method name (not
|
||||||
|
the bare `acs`-style identifier), destination country, shipping total, and each
|
||||||
|
`shippingBreakdown` line item individually (carrier rate, plus any payment-method fee — see
|
||||||
|
0.16.3's `ApplyPaymentMethodFee`) so staff can see what makes up the total, not just the sum.
|
||||||
|
Guards around `Lunar\Models\ProductVariant::getDescription()`/`getOption()`: both are typed to
|
||||||
|
return `string` but internally read `translateAttribute()`/`translate()`, which return `null`
|
||||||
|
for a product/option with no attribute data set for the active locale — a real `TypeError` hit
|
||||||
|
live against an existing test-fixture product. Reads the underlying relations directly instead
|
||||||
|
of calling through those methods, falling back to "—" rather than crashing the page.
|
||||||
|
|
||||||
|
- `Modules\Core\Payment\Drivers\CashOnDeliveryPaymentDriver` — cash-on-delivery/cash-on-pickup was
|
||||||
|
previously wired to `OfflinePaymentDriver`, the same immediate-capture driver as cash-in-hand,
|
||||||
|
which meant a COD order was marked paid the instant it was placed even though no money had
|
||||||
|
actually changed hands. The new driver's `pay()` returns `PaymentResultStatus::Pending` and
|
||||||
|
dispatches nothing, so payment stays unresolved until staff explicitly confirm cash was received
|
||||||
|
(see `Order::paid`/`paid_at` below). A data migration repoints the already-seeded
|
||||||
|
`cash-on-delivery` `PaymentMethod` row to the new driver key.
|
||||||
|
- `Order::paid`/`paid_at` — an entirely independent boolean/timestamp pair tracking payment,
|
||||||
|
settable at any point in an order's lifecycle regardless of fulfillment progress. Exists because
|
||||||
|
cash-on-delivery payment timing has no relationship to the fulfillment sequence at all — a
|
||||||
|
courier might not reconcile cash for weeks after an order is already marked completed.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Order status model, redesigned from scratch.** `Order.status` is a single column again
|
||||||
|
(a same-session 3-axis `payment_status`/`fulfillment_status`/`return_status` design was built,
|
||||||
|
then abandoned before shipping — three independent selects let staff set any combination with no
|
||||||
|
cross-field validation, and didn't map onto how staff actually think about an order: one linear
|
||||||
|
journey, not three simultaneous dials). Now driven by `Modules\Core\Order\Services\
|
||||||
|
OrderStatusFlow`, a pure transition-table service offering exactly two sequences — carrier and
|
||||||
|
store-pickup (`Order::isStorePickupOrder()`) — never four; payment method (prepaid vs. COD)
|
||||||
|
affects `Order::paid` only, not which sequence an order follows or where it sits in it. The
|
||||||
|
Filament order page's several guided buttons are replaced by three header actions: "Update
|
||||||
|
Status" offers every status in the order's own branch (`OrderStatusFlow::allOptions()`) — not
|
||||||
|
just the guided next step — so staff can also revert to an earlier status (e.g. undoing a
|
||||||
|
mistaken click); it also replaces vendor `ManageOrder`'s own built-in "Update Status" (same
|
||||||
|
action name, previously left in place unintentionally, producing two duplicate buttons), since
|
||||||
|
vendor's writes `status` directly with no audit trail or branch validation. It is a PLAIN status
|
||||||
|
write with no side effects — picking 'dispatched' there does not create a real shipment. "Create
|
||||||
|
Shipment" is its own separate action, visible only for a carrier order at 'ready_for_dispatch'
|
||||||
|
(`OrderFulfillmentService::canCreateShipment()`) — the one action that talks to a real carrier
|
||||||
|
API, so its weight/locker inputs only ever appear for that specific real-world action rather than
|
||||||
|
inside the general-purpose status select for every manual override of 'dispatched'. "Mark Paid"
|
||||||
|
is a third, separate header action — `Order::paid` is independent of `status`, so it doesn't
|
||||||
|
belong bundled into the status select either — visible only when the order's payment method
|
||||||
|
doesn't auto-capture at checkout (currently only cash-on-delivery). New status vocabulary
|
||||||
|
(`awaiting_payment`, `processing`, `ready_for_dispatch`/`ready_for_pickup`, `dispatched`,
|
||||||
|
`delivery_failed`, `picked_up`, `delivered`, `completed`, `return_requested`, `returned`,
|
||||||
|
`partially_refunded`, `refunded`) replaces the old hyphenated 7-value list in
|
||||||
|
`config/lunar/orders.php` — a breaking rename backed by a one-time data migration that maps every
|
||||||
|
existing order onto the new vocabulary (preferring axis-system data where an order was actually
|
||||||
|
moved through it during this session's testing, falling back to the legacy flat status otherwise)
|
||||||
|
and derives `paid` from historical transaction data. (The carrier branch's post-delivery status
|
||||||
|
was initially named `return_window_open`; renamed to `delivered` — same one combined moment,
|
||||||
|
parcel arrived and return window open — via a follow-up migration once the internal name turned
|
||||||
|
out to be a confusing thing for staff to see on an order.) A new "Payment Method" entry on the
|
||||||
|
order summary sidebar (`Order.meta['payment_method']`, falling back to the latest transaction's
|
||||||
|
driver) surfaces which method a shopper actually used, previously shown nowhere on the order
|
||||||
|
page. The order list topbar's tabs (Lunar's own `favourite` config flag) are trimmed to the
|
||||||
|
main-journey statuses only, rather than all twelve — the exception/branch statuses stay reachable
|
||||||
|
via the table's own filter.
|
||||||
|
- "Create Shipment"'s form now branches by carrier (`OrderFulfillmentService::carrierFor()`):
|
||||||
|
- A weight-billed carrier (ACS) gets its weight field pre-filled from the order's own line
|
||||||
|
weights via the new `Modules\Core\Shipping\Support\WeightCalculator` (the same unit-conversion
|
||||||
|
table `AcsRateDriver::totalWeightInKg()` already used for live rate quoting, now shared rather
|
||||||
|
than duplicated) — still staff-editable, not forced.
|
||||||
|
- Box Now ships by compartment size, not weight, so it gets a repeatable list of boxes (one row
|
||||||
|
per physical parcel, each with its own S/M/L size — `ShipmentRequest::$boxes`) instead of the
|
||||||
|
weight field. `BoxNowFulfillmentService::createShipment()` sends one `items` entry per box in a
|
||||||
|
single delivery request and now creates one `Shipment` row per parcel returned (was hardcoded to
|
||||||
|
exactly one box/compartmentSize=1, silently ignoring anything beyond the first parcel) — each row
|
||||||
|
independently trackable/printable/cancellable, linked to its siblings via a shared
|
||||||
|
`meta['delivery_request_id']`.
|
||||||
|
- Box Now's locker field is locked read-only once the shopper's own checkout selection
|
||||||
|
(`$order->shippingAddress->meta['box_now_locker']`) is present — staff can no longer silently
|
||||||
|
redirect a parcel to a different locker than the one the customer picked at checkout; it's only
|
||||||
|
editable for the (current, checkout-UI-less) case where nothing set it yet.
|
||||||
|
- New "Shipments" section on the order page (`Modules\Core\Shipping\Extensions\
|
||||||
|
OrderShipmentsExtension`, between Transactions and Timeline) — "Create Shipment" previously had no
|
||||||
|
counterpart anywhere to actually see what it created. One entry per `Shipment` record (a multi-box
|
||||||
|
Box Now order shows one entry per parcel), rendered as two inline-labelled lines — carrier +
|
||||||
|
tracking reference, then status + a "Created … · Locker …" helper line — rather than a grid of
|
||||||
|
individually stacked label/value blocks, which reads as a wall of repeated labels once the admin's
|
||||||
|
main content area narrows below Filament's own grid breakpoint (1024px, common with the sidebar
|
||||||
|
open). Two actions per shipment: "Print Label" and "Cancel". Also added `Modules\Core\Shipping\
|
||||||
|
Http\Controllers\DownloadShipmentLabelController` (short-lived signed URL, same auth model as
|
||||||
|
Lunar's own vendor order-PDF download) — the only other place that called
|
||||||
|
`CarrierFulfillmentInterface::printLabel()` (`ManagePickupManifests`' bulk "Print" action)
|
||||||
|
discarded the returned bytes entirely; this is the first place in the codebase that actually
|
||||||
|
delivers a label to staff. Hit and fixed two bugs while wiring this up: a `TextEntry` with a blank
|
||||||
|
`state('')` skips rendering its `suffixActions()` entirely (Filament's own empty-state branch
|
||||||
|
returns before reaching the actions markup), so the label-download entry needed a real,
|
||||||
|
non-blank value; and the label-download route, registered via `loadRoutesFrom()` with no
|
||||||
|
middleware group, had `SubstituteBindings` never run, so a type-hinted `Shipment $shipment`
|
||||||
|
parameter silently resolved to an empty, non-existent model instead of 404ing — fixed by taking a
|
||||||
|
plain `int $shipment` and looking the record up directly in the controller.
|
||||||
|
- `Modules\Core\Shipping\Enums\TrackingStatus::Failed` — previously unused — is now wired to the
|
||||||
|
new `delivery_failed` status via `Modules\Core\Order\Listeners\
|
||||||
|
MarkDeliveryFailedOnCarrierCheckpoint`, from which staff can retry dispatch or convert to a
|
||||||
|
return.
|
||||||
|
- Fixed a separate, unrelated bug hit while testing the above: `Lunar\Shipping\Models\
|
||||||
|
ShippingMethod::macro('isStorePickup', ...)` silently never registered — `Lunar\Base\Traits\
|
||||||
|
HasModelExtending::__callStatic()` (used by every `Lunar\Base\BaseModel` subclass that doesn't
|
||||||
|
declare its own `macro()`, `ShippingMethod` included) intercepts *every* unmatched static call
|
||||||
|
and dispatches it as an instance call instead of forwarding to `Macroable`, so `hasMacro()` always
|
||||||
|
returned `false` and every order was silently treated as carrier-fulfilled — including store-pickup
|
||||||
|
ones. `Order::isStorePickupOrder()` (the only caller) now reads `ShippingMethod.data
|
||||||
|
['fulfillment_type']` directly instead of going through the broken macro.
|
||||||
|
- `CartResource::getEloquentQuery()` no longer filters to carts with a known `user_id`/
|
||||||
|
`customer_id` — every cart is now listed, guest carts included. Reverses an earlier deliberate
|
||||||
|
exclusion (an anonymous cart has nothing a staff member could click into — no name, no email),
|
||||||
|
which held for that specific concern but not for the resource's other real use: seeing how many
|
||||||
|
carts are ongoing/abandoned right now. Most real storefront traffic never reaches an identified
|
||||||
|
user/customer, so excluding it silently undercounted exactly what `CartLifecycleService` exists
|
||||||
|
to report on. A guest row's Customer/User columns just render "—" (no link) rather than the row
|
||||||
|
being hidden.
|
||||||
|
- `CartLifecycleService::abandonedCarts()` now requires `whereHas('lines')` — an empty cart
|
||||||
|
(created but nothing ever added, e.g. a bot, or a session that never shopped) is no longer
|
||||||
|
counted as "abandoned." There's nothing to recover, so it was a false positive: 9 of 16 carts in
|
||||||
|
the "Abandoned Cart" tab during testing were empty. Removed the now-redundant post-hoc
|
||||||
|
`lines->isEmpty()` skip (and its `with('lines')` eager load) from `DetectAbandonedCarts`, since
|
||||||
|
the query itself excludes them now.
|
||||||
|
- `Modules\Core\Shipping\Models\Manifest` — a real record of "a manifest was issued", replacing the
|
||||||
|
loose `shipments.manifest_reference` string. ACS's own `ACS_Issue_Pickup_List` call returns
|
||||||
|
nothing beyond a `PickupList_No`, so there was previously no way to see which shipments were on a
|
||||||
|
given manifest, or when it was issued, once the moment passed — only per-shipment breadcrumbs.
|
||||||
|
`shipments.manifest_id` (FK, replacing `manifest_reference`) now links each shipment to the
|
||||||
|
`Manifest` row `AcsFulfillmentService::issueManifest()` creates; `ManifestResult::success()`
|
||||||
|
carries the created `Manifest` instead of a bare reference string. A one-time data migration
|
||||||
|
backfills a `Manifest` row per distinct existing `(carrier, manifest_reference)` pair, using the
|
||||||
|
earliest `label_printed_at` (or `updated_at`) among that group as a best-effort `issued_at`, since
|
||||||
|
the real issue time was never recorded anywhere.
|
||||||
|
- Split the standalone `Modules\Core\Shipping\Filament\Pages\ManagePickupManifests` page into two
|
||||||
|
real Filament resources — a bare `Page` has no access to Filament's resource-level pill-tab UI
|
||||||
|
(`HasTabs` is scoped to `ListRecords`), which carrier-by-carrier separation needed:
|
||||||
|
- `Modules\Core\Shipping\Filament\Resources\ShipmentResource` ("Pending Vouchers") — shipments not
|
||||||
|
yet on an issued manifest, one tab per carrier that implements `SupportsManifestBatching` (ACS
|
||||||
|
today; Box Now has no manifest concept at all — courier pickup is booked at shipment-creation
|
||||||
|
time — so it gets no tab). Adding a future carrier with its own manifest endpoints (e.g.
|
||||||
|
Speedex) needs zero UI changes here — tabs are derived from `Shipping::getSupportedDrivers()`,
|
||||||
|
not hardcoded.
|
||||||
|
- `Modules\Core\Shipping\Filament\Resources\ManifestResource` ("Issued Manifests") — lists issued
|
||||||
|
`Manifest` rows (also tabbed by carrier), with a view page and a `ShipmentsRelationManager`
|
||||||
|
showing which shipments a manifest included, each individually reprintable.
|
||||||
|
- Both bulk actions ("Print selected", "Issue Manifest") now catch `Throwable` around the actual
|
||||||
|
carrier API call and surface a Filament notification instead of an unhandled 500 — previously
|
||||||
|
neither had any error handling at all, so an `AcsApiException` (routine against a voucher/pickup
|
||||||
|
date the carrier no longer recognizes) crashed the whole page.
|
||||||
|
- Fixed a bug introduced while building this: `ViewManifest` initially overrode
|
||||||
|
`getRelationManagers()` directly instead of registering `ShipmentsRelationManager` via
|
||||||
|
`ManifestResource::getRelations()` (the actual wiring point —
|
||||||
|
`HasRelationManagers::getAllRelationManagers()` reads from `Resource::getRelations()`, not a
|
||||||
|
page-level override). The override bypassed the trait's own record-check/caching logic and
|
||||||
|
broke the relation manager's Livewire component mount, surfacing as a CSRF-token 419 redirect
|
||||||
|
loop specifically on `/boboko/manifests/{id}`.
|
||||||
|
- "Create Shipment"'s ACS branch gained a "Number of packages" field (`ShipmentRequest::
|
||||||
|
$packageCount`, already plumbed through to ACS's `Item_Quantity`/`persistMultipartVouchers()` but
|
||||||
|
never exposed in the form) — more than 1 issues a main voucher plus a multi-part sub-voucher per
|
||||||
|
extra package, each its own `Shipment` row sharing the same total weight. The existing weight
|
||||||
|
field was relabeled "Total weight (kg)" to make explicit that ACS bills by one total shipment
|
||||||
|
weight, not per package.
|
||||||
|
|
||||||
## [0.16.3] - 2026-09-10
|
## [0.16.3] - 2026-09-10
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
Reference in New Issue
Block a user