Files
core/docs/scratch/orders-feature-survey.html

451 lines
21 KiB
HTML

<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 &amp; 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 &amp; 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>