Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3497553b41 | ||
|
|
29e77a973b | ||
|
|
026ab5bc2d | ||
|
|
483c0ce00f |
@@ -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.11.1] - 2026-09-01
|
||||
|
||||
### Fixed
|
||||
- `Modules\Core\Catalog\Services\RecommendationService::recommend()` built its result with the base `Illuminate\Support\Collection` (`collect()`) instead of `Illuminate\Database\Eloquent\Collection`, even though every element is a `Product` model. `ProductIndexer::toSearchableArray()` calling `->load(['media', 'variants.prices'])` on that result threw `BadMethodCallException: Method Illuminate\Support\Collection::load does not exist` — silently failing every `MakeSearchable` queue job for a saved product (visible only as `FAIL` in the queue log, with the real exception in `storage/logs/laravel.log`). Fixed by having `RecommendationService` accumulate into a real `Eloquent\Collection` from the start.
|
||||
|
||||
## [0.11.0] - 2026-09-01
|
||||
|
||||
### Added
|
||||
|
||||
+3
-2
@@ -2,7 +2,7 @@
|
||||
"name": "boboko/core",
|
||||
"description": "Core module — authentication and shared panel behaviour",
|
||||
"type": "library",
|
||||
"version": "0.11.0",
|
||||
"version": "0.11.1",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Modules\\Core\\": "src/"
|
||||
@@ -42,7 +42,8 @@
|
||||
"Modules\\Core\\Providers\\CatalogServiceProvider",
|
||||
"Modules\\Core\\Providers\\CartServiceProvider",
|
||||
"Modules\\Core\\Providers\\ReviewServiceProvider",
|
||||
"Modules\\Core\\Providers\\ShippingServiceProvider"
|
||||
"Modules\\Core\\Providers\\ShippingServiceProvider",
|
||||
"Modules\\Core\\Providers\\OrderServiceProvider"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
<title>Order Feature Survey</title>
|
||||
<style>
|
||||
:root {
|
||||
--paper: #FAFAF7;
|
||||
--ink: #1C1C1A;
|
||||
--muted: #6B6B63;
|
||||
--accent: #2F5D50;
|
||||
--accent-soft: #E4EDE9;
|
||||
--good: #3F7A5C;
|
||||
--good-soft: #E6F0EA;
|
||||
--warn: #B8863B;
|
||||
--warn-soft: #F5ECDC;
|
||||
--miss: #A14B3B;
|
||||
--miss-soft: #F5E5E0;
|
||||
--hairline: #E4E2DB;
|
||||
--card: #FFFFFF;
|
||||
}
|
||||
|
||||
:root:not([data-theme="light"]) {
|
||||
@media (prefers-color-scheme: dark) {
|
||||
--paper: #17181A;
|
||||
--ink: #EDEBE4;
|
||||
--muted: #9B9A90;
|
||||
--accent: #7FBFA8;
|
||||
--accent-soft: #1E2C27;
|
||||
--good: #6FBF97;
|
||||
--good-soft: #1B2A22;
|
||||
--warn: #D9A85C;
|
||||
--warn-soft: #2C2418;
|
||||
--miss: #D97C68;
|
||||
--miss-soft: #2E1E1A;
|
||||
--hairline: #2C2D2E;
|
||||
--card: #1E1F21;
|
||||
}
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
--paper: #17181A;
|
||||
--ink: #EDEBE4;
|
||||
--muted: #9B9A90;
|
||||
--accent: #7FBFA8;
|
||||
--accent-soft: #1E2C27;
|
||||
--good: #6FBF97;
|
||||
--good-soft: #1B2A22;
|
||||
--warn: #D9A85C;
|
||||
--warn-soft: #2C2418;
|
||||
--miss: #D97C68;
|
||||
--miss-soft: #2E1E1A;
|
||||
--hairline: #2C2D2E;
|
||||
--card: #1E1F21;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
font-family: "IBM Plex Sans", ui-sans-serif, system-ui, sans-serif;
|
||||
font-size: 15.5px;
|
||||
line-height: 1.55;
|
||||
margin: 0;
|
||||
padding: 4.5rem 1.5rem 6rem;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
max-width: 780px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
header.page {
|
||||
margin-bottom: 3.25rem;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-family: "IBM Plex Mono", ui-monospace, monospace;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
margin-bottom: 0.9rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-family: "Fraunces", Georgia, serif;
|
||||
font-weight: 560;
|
||||
font-size: clamp(2.1rem, 4.5vw, 2.65rem);
|
||||
line-height: 1.08;
|
||||
letter-spacing: -0.01em;
|
||||
margin: 0 0 0.9rem;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.dek {
|
||||
color: var(--muted);
|
||||
max-width: 60ch;
|
||||
font-size: 1.02rem;
|
||||
}
|
||||
|
||||
.dek strong {
|
||||
color: var(--ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.6rem;
|
||||
margin-top: 1.6rem;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-family: "IBM Plex Mono", ui-monospace, monospace;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 0.28rem 0.6rem;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.chip.have { background: var(--good-soft); color: var(--good); }
|
||||
.chip.partial { background: var(--warn-soft); color: var(--warn); }
|
||||
.chip.missing { background: var(--miss-soft); color: var(--miss); }
|
||||
|
||||
section.category {
|
||||
margin-top: 3rem;
|
||||
}
|
||||
|
||||
.cat-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.85rem;
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
padding-bottom: 0.7rem;
|
||||
margin-bottom: 1.1rem;
|
||||
}
|
||||
|
||||
.cat-num {
|
||||
font-family: "Fraunces", Georgia, serif;
|
||||
font-size: 1.05rem;
|
||||
color: var(--accent);
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 1.6rem;
|
||||
}
|
||||
|
||||
.cat-head h2 {
|
||||
font-family: "Fraunces", Georgia, serif;
|
||||
font-weight: 500;
|
||||
font-size: 1.28rem;
|
||||
margin: 0;
|
||||
letter-spacing: -0.005em;
|
||||
}
|
||||
|
||||
.cat-note {
|
||||
color: var(--muted);
|
||||
font-size: 0.86rem;
|
||||
margin: 0 0 1.2rem;
|
||||
max-width: 62ch;
|
||||
}
|
||||
|
||||
.feature {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 0.3rem 1rem;
|
||||
padding: 1.05rem 0;
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.feature:last-child { border-bottom: none; }
|
||||
|
||||
.f-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.98rem;
|
||||
}
|
||||
|
||||
.f-status {
|
||||
font-family: "IBM Plex Mono", ui-monospace, monospace;
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
padding: 0.22rem 0.55rem;
|
||||
border-radius: 3px;
|
||||
white-space: nowrap;
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
.f-status.have { background: var(--good-soft); color: var(--good); }
|
||||
.f-status.partial { background: var(--warn-soft); color: var(--warn); }
|
||||
.f-status.missing { background: var(--miss-soft); color: var(--miss); }
|
||||
|
||||
.f-note {
|
||||
grid-column: 1 / -1;
|
||||
color: var(--muted);
|
||||
font-size: 0.87rem;
|
||||
margin-top: 0.15rem;
|
||||
max-width: 66ch;
|
||||
}
|
||||
|
||||
.f-note code {
|
||||
font-family: "IBM Plex Mono", ui-monospace, monospace;
|
||||
font-size: 0.82em;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
padding: 0.08em 0.35em;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
footer.page {
|
||||
margin-top: 4rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid var(--hairline);
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
footer.page a { color: var(--accent); }
|
||||
|
||||
@media (max-width: 560px) {
|
||||
body { padding: 3rem 1.1rem 4rem; }
|
||||
.feature { grid-template-columns: 1fr; }
|
||||
.f-status { justify-self: start; }
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400..600&family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap">
|
||||
|
||||
<div class="wrap">
|
||||
|
||||
<header class="page">
|
||||
<div class="eyebrow">boboko / order · competitive survey</div>
|
||||
<h1>What order management elsewhere can do that boboko can't yet</h1>
|
||||
<p class="dek">
|
||||
Where the Checkout survey stopped — the instant <code>Order</code> exists — this
|
||||
one starts. A feature-by-feature pass across Shopify, WooCommerce, PrestaShop, and
|
||||
(briefly) Magento's post-placement order layer — sourced, not recalled from memory —
|
||||
checked against what <strong>Lunar's <code>Order</code> model and the
|
||||
already-shipped Filament <code>ManageOrder</code> page</strong> actually support
|
||||
today. For deciding what the new <code>Order</code> module needs to own, not a
|
||||
build order.
|
||||
</p>
|
||||
<div class="legend">
|
||||
<span class="chip have">● have</span>
|
||||
<span class="chip partial">◐ partial</span>
|
||||
<span class="chip missing">○ missing</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="category">
|
||||
<div class="cat-head">
|
||||
<span class="cat-num">01</span>
|
||||
<h2>Status model</h2>
|
||||
</div>
|
||||
<p class="cat-note">One field, or several axes — and who's allowed to move it.</p>
|
||||
|
||||
<div class="feature">
|
||||
<div class="f-name">Payment status independent of a single overall status</div>
|
||||
<span class="f-status partial">partial</span>
|
||||
<div class="f-note">The data exists — <code>ManageOrder::paymentStatus()</code> derives a real value from <code>transactions()</code>/<code>captureTotal()</code>/<code>refundTotal()</code>/<code>intentTotal()</code> — but it's a computed display value on the admin page, not a stored column or something the rest of the system (mailers, automations) can key off. Shopify and Magento both make payment status a first-class, independently-queryable dimension; here it's derived on the fly, once, in one Filament page.</div>
|
||||
</div>
|
||||
|
||||
<div class="feature">
|
||||
<div class="f-name">Fulfillment status independent of overall status</div>
|
||||
<span class="f-status missing">missing</span>
|
||||
<div class="f-note">No equivalent of <code>paymentStatus()</code> exists for shipment/fulfillment state — <code>Order</code> has no <code>shipments()</code> relation of its own at all; it's added dynamically by <code>Modules\Core\Shipping\Providers\ShippingServiceProvider::resolveRelationUsing()</code>, outside Order's own boundary (see docs/checkout.md, "Where Order would likely absorb work"). Every platform researched (Shopify, Woo, PrestaShop, Magento) treats "has this shipped" as derivable from child records, not a manually-set field — Lunar has the child records (<code>Shipment</code>) but no derived status method reading them.</div>
|
||||
</div>
|
||||
|
||||
<div class="feature">
|
||||
<div class="f-name">Staff-editable order status with a picker/action</div>
|
||||
<span class="f-status have">have</span>
|
||||
<div class="f-note"><code>ManageOrder</code> ships a working <code>UpdateStatusAction</code> out of the box, backed by <code>config('lunar.orders.statuses')</code> — a flat, merchant-configured list, each entry carrying a <code>label</code>/<code>color</code>/<code>favourite</code> flag. Closer to WooCommerce's single linear field than Shopify's multi-axis split.</div>
|
||||
</div>
|
||||
|
||||
<div class="feature">
|
||||
<div class="f-name">Status rows carry behavior (auto-send email, generate invoice, restock)</div>
|
||||
<span class="f-status partial">partial</span>
|
||||
<div class="f-note">Each status entry in <code>config('lunar.orders.statuses')</code> already declares <code>mailers</code> and <code>notifications</code> arrays — the PrestaShop-style shape is there in config — but per the Checkout survey's finding, nothing in core actually reads and dispatches from those keys on a transition. The data model for "status carries behavior" exists; the behavior doesn't.</div>
|
||||
</div>
|
||||
|
||||
<div class="feature">
|
||||
<div class="f-name">Order-status-changed event other code can react to</div>
|
||||
<span class="f-status missing">missing</span>
|
||||
<div class="f-note">Same gap the Checkout survey flagged for order creation: no <code>OrderStatusUpdated</code>/equivalent exists anywhere in core. <code>UpdateStatusAction</code> just writes the column. Anything wanting to react to a status change — a confirmation email, a webhook, re-deriving payment/fulfillment status — has to hook the raw Eloquent <code>Order::updated()</code> event and diff <code>status</code> itself.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="category">
|
||||
<div class="cat-head">
|
||||
<span class="cat-num">02</span>
|
||||
<h2>Fulfillment & shipment tracking</h2>
|
||||
</div>
|
||||
<p class="cat-note">Turning a placed order into a package that moves.</p>
|
||||
|
||||
<div class="feature">
|
||||
<div class="f-name">Shipment as its own record, separate from the order</div>
|
||||
<span class="f-status have">have</span>
|
||||
<div class="f-note"><code>Modules\Core\Shipping\Models\Shipment</code> (carrier, tracking reference, label-printed timestamp, manifest reference) already exists and belongs to <code>Order</code>. Built this session, ahead of most gaps in this survey — the record shape is closer to Magento's per-shipment entity than Woo's "no shipment entity at all."</div>
|
||||
</div>
|
||||
|
||||
<div class="feature">
|
||||
<div class="f-name">Multiple shipments per order (partial/split fulfillment)</div>
|
||||
<span class="f-status partial">partial</span>
|
||||
<div class="f-note"><code>Shipment</code> has no <code>quantity</code>-per-line or <code>order_line_id</code> concept — it's one shipment record per carrier voucher, with a <code>parent_reference</code> for ACS's own multipart-voucher case (one physical order split into multiple packages by the carrier), not a per-line-item fulfillment split decided by staff. Closer to "multiple packages for one shipment" than Magento's true per-line partial-shipment model.</div>
|
||||
</div>
|
||||
|
||||
<div class="feature">
|
||||
<div class="f-name">Create-shipment action from the order admin screen</div>
|
||||
<span class="f-status have">have</span>
|
||||
<div class="f-note"><code>Modules\Core\Shipping\Extensions\OrderViewExtension</code> adds a working "Create Shipment" header action to <code>ManageOrder</code>, resolving a <code>CarrierFulfillmentInterface</code> by the order's chosen shipping method and calling <code>createShipment()</code> — genuinely wired, not a stub. Currently lives under <code>Shipping</code>, flagged in docs/checkout.md as conceptually an <code>Order</code> concern.</div>
|
||||
</div>
|
||||
|
||||
<div class="feature">
|
||||
<div class="f-name">Tracking number + carrier surfaced on the order itself</div>
|
||||
<span class="f-status have">have</span>
|
||||
<div class="f-note"><code>Shipment.tracking_reference</code>/<code>carrier</code> exist and are populated by <code>createShipment()</code>; <code>PollShipmentTrackingJob</code> (scheduled every 30 minutes) keeps <code>ShipmentInfo</code> checkpoints current via <code>CarrierFulfillmentInterface::trackShipment()</code>. Genuinely ahead of PrestaShop's thin <code>order_carrier.tracking_number</code> field — this has a real checkpoint history, not just one string.</div>
|
||||
</div>
|
||||
|
||||
<div class="feature">
|
||||
<div class="f-name">"Shipped"/"delivered" status auto-derived from tracking</div>
|
||||
<span class="f-status missing">missing</span>
|
||||
<div class="f-note">The tracking checkpoints exist (<code>ShipmentInfo</code>, <code>TrackingStatus</code> enum including <code>Delivered</code>) but nothing writes them back onto <code>Order.status</code> — a delivered shipment doesn't move the order out of whatever status it was already in. Every platform researched treats "delivered" as a status a customer/staff can see on the order, not something buried one relation away.</div>
|
||||
</div>
|
||||
|
||||
<div class="feature">
|
||||
<div class="f-name">Shipping/delivery notification emails (shipped, out-for-delivery, delivered)</div>
|
||||
<span class="f-status missing">missing</span>
|
||||
<div class="f-note">Research: Shopify fires four separate templated notifications across this window alone (shipping confirmation, out-for-delivery, delivered, plus edited-order). None of the pieces exist here — no order-status-changed event (01) to trigger from, and no mailer wired to <code>PollShipmentTrackingJob</code>'s own status updates either.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="category">
|
||||
<div class="cat-head">
|
||||
<span class="cat-num">03</span>
|
||||
<h2>Payments: capture, refund, cancellation</h2>
|
||||
</div>
|
||||
<p class="cat-note">Money moving back out, and orders that never should have been placed.</p>
|
||||
|
||||
<div class="feature">
|
||||
<div class="f-name">Refund action from the order screen, amount-scoped</div>
|
||||
<span class="f-status have">have</span>
|
||||
<div class="f-note"><code>ManageOrder</code>'s <code>refund</code> action already exists — picks a transaction, an amount (validated against <code>availableToRefund()</code>), and notes, then calls the driver's own <code>Transaction::refund()</code>. This is genuinely native, matching Woo/Magento's line-item-adjacent (if not line-item-exact) refund UX.</div>
|
||||
</div>
|
||||
|
||||
<div class="feature">
|
||||
<div class="f-name">Capture action for auth-then-capture payment flows</div>
|
||||
<span class="f-status have">have</span>
|
||||
<div class="f-note"><code>ManageOrder</code>'s <code>capture</code> action + <code>requiresCapture()</code>/<code>canBeRefunded()</code> guard methods already exist, delegating to <code>Transaction::capture()</code> — this is the Stripe "authorize now, capture later" flow's admin-side half, already built ahead of most gaps here.</div>
|
||||
</div>
|
||||
|
||||
<div class="feature">
|
||||
<div class="f-name">Refund tied to specific line items (not just a dollar amount)</div>
|
||||
<span class="f-status missing">missing</span>
|
||||
<div class="f-note">The refund action takes a transaction + amount, with no line-item selection or restock decision — WooCommerce and Magento both make "which items, how many, restock or not" the primary refund UI; here it's one number against one transaction, closer to a manual adjustment than a structured partial return.</div>
|
||||
</div>
|
||||
|
||||
<div class="feature">
|
||||
<div class="f-name">Order cancellation as a distinct action (vs. just changing status)</div>
|
||||
<span class="f-status missing">missing</span>
|
||||
<div class="f-note">No dedicated "cancel" action exists on <code>ManageOrder</code> — a cancellation today would just be picking a "cancelled"-labeled entry from the generic status dropdown (01), with no automatic refund trigger, no stock-release logic, and no distinction from any other manual status edit.</div>
|
||||
</div>
|
||||
|
||||
<div class="feature">
|
||||
<div class="f-name">Refund/capture reflected back into an order-level payment status</div>
|
||||
<span class="f-status partial">partial</span>
|
||||
<div class="f-note">Same gap as 01's payment-status finding — <code>paymentStatus()</code> recomputes correctly from transactions when the admin page loads, but a refund doesn't push the order into a <code>refunded</code>/<code>partially-refunded</code> overall status the way Shopify's <code>displayFinancialStatus</code> does automatically.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="category">
|
||||
<div class="cat-head">
|
||||
<span class="cat-num">04</span>
|
||||
<h2>Returns (RMA)</h2>
|
||||
</div>
|
||||
<p class="cat-note">The one area every researched platform treats as optional, not core.</p>
|
||||
|
||||
<div class="feature">
|
||||
<div class="f-name">Return-merchandise-authorization flow (customer requests, staff approves)</div>
|
||||
<span class="f-status missing">missing</span>
|
||||
<div class="f-note">No <code>Return</code>/RMA model, status set, or request flow exists anywhere in this codebase. Consistent with the research: Shopify is the only platform of the four with this genuinely native; PrestaShop ships it off-by-default; Magento gates it behind the paid Adobe Commerce tier; WooCommerce lacks it entirely. Safe to treat as a real gap, not an urgent one.</div>
|
||||
</div>
|
||||
|
||||
<div class="feature">
|
||||
<div class="f-name">Return shipping label generation</div>
|
||||
<span class="f-status missing">missing</span>
|
||||
<div class="f-note">Depends entirely on the RMA flow above existing first — <code>CarrierFulfillmentInterface</code> already has the label-printing primitive (<code>printLabel()</code>) a return label would reuse, so the carrier-side plumbing isn't the blocker, the RMA request/approval model is.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="category">
|
||||
<div class="cat-head">
|
||||
<span class="cat-num">05</span>
|
||||
<h2>Order editing</h2>
|
||||
</div>
|
||||
<p class="cat-note">Changing a placed order — and where every platform draws the line.</p>
|
||||
|
||||
<div class="feature">
|
||||
<div class="f-name">Editing guardrails keyed to fulfillment state</div>
|
||||
<span class="f-status missing">missing</span>
|
||||
<div class="f-note">No line-item add/remove exists on a placed order at all today (unlike Shopify/Woo/PrestaShop, which all allow it up to some fulfillment-keyed cutoff, then force a return instead) — so there's no guardrail to speak of yet because there's no editing to guard. Whatever gets built here should key the cutoff to <code>Shipment</code> existing, per the pattern all four researched platforms converge on.</div>
|
||||
</div>
|
||||
|
||||
<div class="feature">
|
||||
<div class="f-name">Editable shipping/billing address after placement</div>
|
||||
<span class="f-status missing">missing</span>
|
||||
<div class="f-note"><code>OrderAddress</code> rows are snapshotted at creation (see Checkout survey, 02) and nothing in <code>ManageOrder</code> exposes editing them afterward — every platform researched treats address edits as lower-risk than line-item edits and allows them more freely; this codebase currently allows neither.</div>
|
||||
</div>
|
||||
|
||||
<div class="feature">
|
||||
<div class="f-name">Tag editing on a placed order</div>
|
||||
<span class="f-status have">have</span>
|
||||
<div class="f-note"><code>ManageOrder</code>'s <code>edit_tags</code> action already works — the one piece of native post-placement editing that exists today, via <code>HasTags</code> on the <code>Order</code> model.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="category">
|
||||
<div class="cat-head">
|
||||
<span class="cat-num">06</span>
|
||||
<h2>Notes & audit trail</h2>
|
||||
</div>
|
||||
<p class="cat-note">The one thing every researched platform treats as non-negotiable.</p>
|
||||
|
||||
<div class="feature">
|
||||
<div class="f-name">Append-only change history (who changed what, when)</div>
|
||||
<span class="f-status have">have</span>
|
||||
<div class="f-note"><code>Order</code> already uses Spatie's <code>LogsActivity</code> trait — every save is recorded with a diff, same underlying mechanism already relied on elsewhere in this codebase (staff activity log, translation history). Structurally equivalent to PrestaShop's <code>order_history</code> table, just via a different package.</div>
|
||||
</div>
|
||||
|
||||
<div class="feature">
|
||||
<div class="f-name">Internal staff notes, separate from system-generated log entries</div>
|
||||
<span class="f-status missing">missing</span>
|
||||
<div class="f-note">The activity log above captures field changes automatically, but there's no free-text "leave a note for the next person" field — every platform researched has this as a distinct feed from the automatic history (Woo's Order Notes, Shopify's Timeline comments, Magento's Comments History), usually with a private-vs-customer-visible toggle. Nothing here yet.</div>
|
||||
</div>
|
||||
|
||||
<div class="feature">
|
||||
<div class="f-name">Customer-visible note-to-customer, sent as a message</div>
|
||||
<span class="f-status missing">missing</span>
|
||||
<div class="f-note">Depends on both the internal-notes feature above and a working mailer (01/02) — genuinely blocked on more foundational gaps, not just unbuilt on its own.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="page">
|
||||
<span>Compiled 2026-09-01 — sources cited inline; <code>vendor/lunarphp/lunar</code> and this codebase's own <code>src/</code> reads are marked by file/class name, Shopify/WooCommerce/PrestaShop/Magento claims are marked "Research."</span>
|
||||
<span>boboko-core / docs</span>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,3 @@
|
||||
<p>Hi,</p>
|
||||
|
||||
<p>Payment of <strong>{{ $amount }}</strong> for your order <strong>{{ $reference }}</strong> has been captured.</p>
|
||||
@@ -0,0 +1,3 @@
|
||||
<p>Hi,</p>
|
||||
|
||||
<p>Good news — your order <strong>{{ $reference }}</strong> has been delivered.</p>
|
||||
@@ -0,0 +1,3 @@
|
||||
<p>Hi,</p>
|
||||
|
||||
<p>A refund of <strong>{{ $amount }}</strong> has been issued for your order <strong>{{ $reference }}</strong>.</p>
|
||||
@@ -0,0 +1,3 @@
|
||||
<p>Hi,</p>
|
||||
|
||||
<p>Your order <strong>{{ $reference }}</strong> is now: <strong>{{ $statusLabel }}</strong></p>
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace Modules\Core\Catalog\Services;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Lunar\Models\Product;
|
||||
use Modules\Core\Catalog\Contracts\RecommendationRule;
|
||||
|
||||
@@ -25,7 +25,7 @@ class RecommendationService
|
||||
*/
|
||||
public function recommend(Product $product, int $limit = 4): Collection
|
||||
{
|
||||
$recommendations = collect();
|
||||
$recommendations = new Collection();
|
||||
|
||||
foreach (config('catalog.recommendation_rules', []) as $ruleClass) {
|
||||
if ($recommendations->count() >= $limit) {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Enums;
|
||||
|
||||
/**
|
||||
* Derived from Shipment/ShipmentInfo — no equivalent existed anywhere in
|
||||
* Lunar or this codebase before OrderStatus::fulfillment().
|
||||
*/
|
||||
enum FulfillmentStatus: string
|
||||
{
|
||||
case Unfulfilled = 'unfulfilled';
|
||||
case Shipped = 'shipped';
|
||||
case PartiallyShipped = 'partially-shipped';
|
||||
case Delivered = 'delivered';
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Enums;
|
||||
|
||||
/**
|
||||
* Same states/logic as Lunar's own ManageOrder::paymentStatus(), which
|
||||
* only exists as a Filament-page Livewire #[Computed] method — this is
|
||||
* that same derivation, reusable from anywhere via Order::paymentStatus().
|
||||
*/
|
||||
enum PaymentStatus: string
|
||||
{
|
||||
case Offline = 'offline';
|
||||
case Uncaptured = 'uncaptured';
|
||||
case Captured = 'captured';
|
||||
case PartialRefund = 'partial-refund';
|
||||
case Refunded = 'refunded';
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Events;
|
||||
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Lunar\Models\Order;
|
||||
use Lunar\Models\Transaction;
|
||||
|
||||
/**
|
||||
* Dispatched by TransactionObserver::saved() when a Transaction's type
|
||||
* changes to 'capture' (from 'intent') and succeeds. Unlike refunds,
|
||||
* Lunar's Stripe driver (StoreCharges) reuses the same Transaction row
|
||||
* across intent -> capture rather than creating a new one, so this can't
|
||||
* key off `wasRecentlyCreated` the way OrderRefunded does.
|
||||
*/
|
||||
class OrderCaptured
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
public function __construct(
|
||||
public readonly Order $order,
|
||||
public readonly Transaction $transaction,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Events;
|
||||
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Lunar\Models\Order;
|
||||
use Modules\Core\Shipping\Models\ShipmentInfo;
|
||||
|
||||
/**
|
||||
* Dispatched by Order's DeriveOrderDeliveredFromShipment listener, which
|
||||
* reacts to Shipping's ShipmentStatusUpdatedByCarrier — delivery is a
|
||||
* tracking checkpoint, not a manual status write, so it never goes through
|
||||
* OrderStatusUpdated. Order.status itself is left untouched here; this is
|
||||
* only the signal for delivery notifications and similar reactions.
|
||||
*/
|
||||
class OrderDelivered
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
public function __construct(
|
||||
public readonly Order $order,
|
||||
public readonly ShipmentInfo $shipmentInfo,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Events;
|
||||
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Lunar\Models\Order;
|
||||
use Lunar\Models\Transaction;
|
||||
|
||||
/**
|
||||
* Dispatched by TransactionObserver::saved() whenever a successful
|
||||
* type=refund Transaction row is written — every payment driver (Lunar's
|
||||
* own StripePaymentType::refund(), or a future boboko-owned driver for a
|
||||
* provider Lunar doesn't ship) creates a new row for each refund, so
|
||||
* `created` alone (filtered to type+success) is enough here, unlike
|
||||
* captures which can reuse an existing row.
|
||||
*/
|
||||
class OrderRefunded
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
public function __construct(
|
||||
public readonly Order $order,
|
||||
public readonly Transaction $transaction,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Events;
|
||||
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Lunar\Models\Order;
|
||||
|
||||
/**
|
||||
* Dispatched by OrderObserver::updated() whenever an Order's status column
|
||||
* changes, regardless of what wrote it — Filament's UpdateStatusAction,
|
||||
* artisan tinker, a future API. Lunar's own UpdatesOrderStatus trait fires
|
||||
* mailers inline, but only for that one admin action; this event is the
|
||||
* general-purpose hook everything else (our own mailers, automations,
|
||||
* derived payment/fulfillment status) should listen to instead.
|
||||
*/
|
||||
class OrderStatusUpdated
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
public function __construct(
|
||||
public readonly Order $order,
|
||||
public readonly ?string $previousStatus,
|
||||
public readonly string $newStatus,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Listeners;
|
||||
|
||||
use Modules\Core\Order\Events\OrderDelivered;
|
||||
use Modules\Core\Shipping\Enums\TrackingStatus;
|
||||
use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier;
|
||||
|
||||
/**
|
||||
* Translates a carrier tracking checkpoint into OrderDelivered — the event
|
||||
* OrderDeliveredNotification (via NotificationRegistry) actually listens
|
||||
* to. Kept separate from the notification itself so the "is this checkpoint
|
||||
* a delivery" filtering doesn't leak into notification code.
|
||||
*/
|
||||
class DeriveOrderDeliveredFromShipment
|
||||
{
|
||||
public function handle(ShipmentStatusUpdatedByCarrier $event): void
|
||||
{
|
||||
if ($event->shipmentInfo->status !== TrackingStatus::Delivered) {
|
||||
return;
|
||||
}
|
||||
|
||||
$order = $event->shipmentInfo->shipment->order;
|
||||
|
||||
if (! $order) {
|
||||
return;
|
||||
}
|
||||
|
||||
OrderDelivered::dispatch($order, $event->shipmentInfo);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Notifications;
|
||||
|
||||
use Illuminate\Notifications\AnonymousNotifiable;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Support\Facades\Notification as NotificationFacade;
|
||||
use Modules\Core\Notification\BaseNotification;
|
||||
use Modules\Core\Order\Events\OrderCaptured;
|
||||
|
||||
class OrderCapturedNotification extends BaseNotification
|
||||
{
|
||||
public function __construct(private readonly OrderCaptured $event) {}
|
||||
|
||||
public static function getKey(): string
|
||||
{
|
||||
return 'order.captured.customer.mail';
|
||||
}
|
||||
|
||||
public static function listensTo(): string
|
||||
{
|
||||
return OrderCaptured::class;
|
||||
}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
public function notifiable(): AnonymousNotifiable
|
||||
{
|
||||
$order = $this->event->order;
|
||||
|
||||
$email = $order->billingAddress?->contact_email ?? $order->shippingAddress?->contact_email;
|
||||
|
||||
return NotificationFacade::route('mail', $email);
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$order = $this->event->order;
|
||||
|
||||
return (new MailMessage)
|
||||
->subject(__('Payment captured for your order :reference', ['reference' => $order->reference]))
|
||||
->view('core::order.notifications.captured', [
|
||||
'reference' => $order->reference,
|
||||
'amount' => $this->event->transaction->amount->formatted,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Notifications;
|
||||
|
||||
use Illuminate\Notifications\AnonymousNotifiable;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Support\Facades\Notification as NotificationFacade;
|
||||
use Modules\Core\Notification\BaseNotification;
|
||||
use Modules\Core\Order\Events\OrderDelivered;
|
||||
|
||||
class OrderDeliveredNotification extends BaseNotification
|
||||
{
|
||||
public function __construct(private readonly OrderDelivered $event) {}
|
||||
|
||||
public static function getKey(): string
|
||||
{
|
||||
return 'order.delivered.customer.mail';
|
||||
}
|
||||
|
||||
public static function listensTo(): string
|
||||
{
|
||||
return OrderDelivered::class;
|
||||
}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
public function notifiable(): AnonymousNotifiable
|
||||
{
|
||||
$order = $this->event->order;
|
||||
|
||||
$email = $order->billingAddress?->contact_email ?? $order->shippingAddress?->contact_email;
|
||||
|
||||
return NotificationFacade::route('mail', $email);
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$order = $this->event->order;
|
||||
|
||||
return (new MailMessage)
|
||||
->subject(__('Your order :reference has been delivered', ['reference' => $order->reference]))
|
||||
->view('core::order.notifications.delivered', [
|
||||
'reference' => $order->reference,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Notifications;
|
||||
|
||||
use Illuminate\Notifications\AnonymousNotifiable;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Support\Facades\Notification as NotificationFacade;
|
||||
use Modules\Core\Notification\BaseNotification;
|
||||
use Modules\Core\Order\Events\OrderRefunded;
|
||||
|
||||
class OrderRefundedNotification extends BaseNotification
|
||||
{
|
||||
public function __construct(private readonly OrderRefunded $event) {}
|
||||
|
||||
public static function getKey(): string
|
||||
{
|
||||
return 'order.refunded.customer.mail';
|
||||
}
|
||||
|
||||
public static function listensTo(): string
|
||||
{
|
||||
return OrderRefunded::class;
|
||||
}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
public function notifiable(): AnonymousNotifiable
|
||||
{
|
||||
$order = $this->event->order;
|
||||
|
||||
$email = $order->billingAddress?->contact_email ?? $order->shippingAddress?->contact_email;
|
||||
|
||||
return NotificationFacade::route('mail', $email);
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$order = $this->event->order;
|
||||
|
||||
return (new MailMessage)
|
||||
->subject(__('A refund has been issued for your order :reference', ['reference' => $order->reference]))
|
||||
->view('core::order.notifications.refunded', [
|
||||
'reference' => $order->reference,
|
||||
'amount' => $this->event->transaction->amount->formatted,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Notifications;
|
||||
|
||||
use Illuminate\Notifications\AnonymousNotifiable;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Support\Facades\Notification as NotificationFacade;
|
||||
use Modules\Core\Notification\BaseNotification;
|
||||
use Modules\Core\Order\Events\OrderStatusUpdated;
|
||||
|
||||
class OrderStatusUpdatedNotification extends BaseNotification
|
||||
{
|
||||
public function __construct(private readonly OrderStatusUpdated $event) {}
|
||||
|
||||
public static function getKey(): string
|
||||
{
|
||||
return 'order.status_updated.customer.mail';
|
||||
}
|
||||
|
||||
public static function listensTo(): string
|
||||
{
|
||||
return OrderStatusUpdated::class;
|
||||
}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
public function notifiable(): AnonymousNotifiable
|
||||
{
|
||||
$order = $this->event->order;
|
||||
|
||||
$email = $order->billingAddress?->contact_email ?? $order->shippingAddress?->contact_email;
|
||||
|
||||
return NotificationFacade::route('mail', $email);
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$order = $this->event->order;
|
||||
|
||||
return (new MailMessage)
|
||||
->subject(__('Your order :reference has been updated', ['reference' => $order->reference]))
|
||||
->view('core::order.notifications.status-updated', [
|
||||
'reference' => $order->reference,
|
||||
'statusLabel' => config("lunar.orders.statuses.{$order->status}.label", $order->status),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Observers;
|
||||
|
||||
use Lunar\Models\Order;
|
||||
use Modules\Core\Order\Events\OrderStatusUpdated;
|
||||
|
||||
class OrderObserver
|
||||
{
|
||||
public function updated(Order $order): void
|
||||
{
|
||||
if (! $order->wasChanged('status')) {
|
||||
return;
|
||||
}
|
||||
|
||||
OrderStatusUpdated::dispatch(
|
||||
$order,
|
||||
$order->getOriginal('status'),
|
||||
$order->status,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Observers;
|
||||
|
||||
use Lunar\Models\Transaction;
|
||||
use Modules\Core\Order\Events\OrderCaptured;
|
||||
use Modules\Core\Order\Events\OrderRefunded;
|
||||
|
||||
class TransactionObserver
|
||||
{
|
||||
public function saved(Transaction $transaction): void
|
||||
{
|
||||
if (! $transaction->success) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($transaction->type === 'refund' && $transaction->wasRecentlyCreated) {
|
||||
OrderRefunded::dispatch($transaction->order, $transaction);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($transaction->type === 'capture' && $transaction->wasChanged('type')) {
|
||||
OrderCaptured::dispatch($transaction->order, $transaction);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Support;
|
||||
|
||||
use Lunar\Models\Order;
|
||||
use Modules\Core\Order\Enums\FulfillmentStatus;
|
||||
use Modules\Core\Order\Enums\PaymentStatus;
|
||||
use Modules\Core\Shipping\Enums\TrackingStatus;
|
||||
|
||||
/**
|
||||
* Payment/fulfillment state, derived on read from transactions and
|
||||
* shipments rather than stored — mirrors the logic Lunar's own
|
||||
* ManageOrder::paymentStatus() computes as a Livewire #[Computed] method
|
||||
* (Filament-page-only, not reusable), reimplemented here as a plain,
|
||||
* queryable value any code can call via Order::macro() in
|
||||
* OrderServiceProvider.
|
||||
*/
|
||||
class OrderStatus
|
||||
{
|
||||
public static function payment(Order $order): PaymentStatus
|
||||
{
|
||||
$transactions = $order->transactions;
|
||||
|
||||
$intentTotal = $transactions
|
||||
->filter(fn ($t) => $t->type === 'intent' && $t->success)
|
||||
->sum('amount.value');
|
||||
|
||||
$captureTotal = $transactions
|
||||
->filter(fn ($t) => $t->type === 'capture' && $t->success)
|
||||
->sum('amount.value');
|
||||
|
||||
$refundTotal = $transactions
|
||||
->filter(fn ($t) => $t->type === 'refund' && $t->success)
|
||||
->sum('amount.value');
|
||||
|
||||
$total = $intentTotal ?: $captureTotal;
|
||||
|
||||
if (! $total) {
|
||||
return PaymentStatus::Offline;
|
||||
}
|
||||
|
||||
if (
|
||||
($refundTotal && $refundTotal < $total) ||
|
||||
($captureTotal && $captureTotal < $intentTotal)
|
||||
) {
|
||||
return PaymentStatus::PartialRefund;
|
||||
}
|
||||
|
||||
if ($refundTotal >= $total) {
|
||||
return PaymentStatus::Refunded;
|
||||
}
|
||||
|
||||
if ($captureTotal >= $intentTotal) {
|
||||
return PaymentStatus::Captured;
|
||||
}
|
||||
|
||||
return PaymentStatus::Uncaptured;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads shipments.shipmentInfo if already eager-loaded (the caller's
|
||||
* job — e.g. Order::with('shipments.shipmentInfo')) and picks the
|
||||
* latest checkpoint in PHP, instead of Shipment::latestShipmentInfo()'s
|
||||
* per-shipment query — calling this across a list of orders would
|
||||
* otherwise be an extra query per shipment.
|
||||
*/
|
||||
public static function fulfillment(Order $order): FulfillmentStatus
|
||||
{
|
||||
$shipments = $order->shipments->reject(fn ($shipment) => $shipment->cancelled_at !== null);
|
||||
|
||||
if ($shipments->isEmpty()) {
|
||||
return FulfillmentStatus::Unfulfilled;
|
||||
}
|
||||
|
||||
$latestStatuses = $shipments->map(function ($shipment) {
|
||||
$latest = $shipment->relationLoaded('shipmentInfo')
|
||||
? $shipment->shipmentInfo->sortByDesc('occurred_at')->first()
|
||||
: $shipment->latestShipmentInfo();
|
||||
|
||||
return $latest?->status ?? TrackingStatus::Pending;
|
||||
});
|
||||
|
||||
if ($latestStatuses->every(fn (TrackingStatus $status) => $status === TrackingStatus::Delivered)) {
|
||||
return FulfillmentStatus::Delivered;
|
||||
}
|
||||
|
||||
if ($latestStatuses->contains(fn (TrackingStatus $status) => $status === TrackingStatus::Delivered)) {
|
||||
return FulfillmentStatus::PartiallyShipped;
|
||||
}
|
||||
|
||||
return FulfillmentStatus::Shipped;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Providers;
|
||||
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Lunar\Models\Order;
|
||||
use Lunar\Models\Transaction;
|
||||
use Modules\Core\Notification\NotificationRegistry;
|
||||
use Modules\Core\Order\Listeners\DeriveOrderDeliveredFromShipment;
|
||||
use Modules\Core\Order\Notifications\OrderCapturedNotification;
|
||||
use Modules\Core\Order\Notifications\OrderDeliveredNotification;
|
||||
use Modules\Core\Order\Notifications\OrderRefundedNotification;
|
||||
use Modules\Core\Order\Notifications\OrderStatusUpdatedNotification;
|
||||
use Modules\Core\Order\Observers\OrderObserver;
|
||||
use Modules\Core\Order\Observers\TransactionObserver;
|
||||
use Modules\Core\Order\Support\OrderStatus;
|
||||
use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier;
|
||||
|
||||
class OrderServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function boot(): void
|
||||
{
|
||||
Order::observe(OrderObserver::class);
|
||||
Transaction::observe(TransactionObserver::class);
|
||||
|
||||
Order::macro('paymentStatus', fn () => OrderStatus::payment($this));
|
||||
Order::macro('fulfillmentStatus', fn () => OrderStatus::fulfillment($this));
|
||||
|
||||
Event::listen(ShipmentStatusUpdatedByCarrier::class, DeriveOrderDeliveredFromShipment::class);
|
||||
|
||||
NotificationRegistry::get()->register([
|
||||
OrderDeliveredNotification::class,
|
||||
OrderStatusUpdatedNotification::class,
|
||||
OrderRefundedNotification::class,
|
||||
OrderCapturedNotification::class,
|
||||
]);
|
||||
|
||||
// Lets the consuming app override copy/markup without forking core
|
||||
// — published into resources/views/vendor/core/order/notifications,
|
||||
// which loadViewsFrom() (CoreServiceProvider) already resolves
|
||||
// ahead of the package's own views for the `core::` namespace.
|
||||
$this->publishes([
|
||||
__DIR__ . '/../../resources/views/order/notifications' => resource_path('views/vendor/core/order/notifications'),
|
||||
], 'core-views');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user