diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ddd8a3..c4905f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/). +## [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 ### Fixed