diff --git a/docs/scratch/analytics-feature-survey.html b/docs/scratch/analytics-feature-survey.html new file mode 100644 index 0000000..18ee9a7 --- /dev/null +++ b/docs/scratch/analytics-feature-survey.html @@ -0,0 +1,449 @@ +
+ A feature-by-feature pass across Shopify, WooCommerce Analytics, and PrestaShop's
+ stats modules — sourced, not recalled from memory — checked against what
+ Lunar's admin Dashboard actually ships today and what
+ raw data already sits in lunar_orders/lunar_carts unused.
+ This is genuinely new territory for boboko — most rows below land on partial or
+ missing, and that's an honest read, not an undersell.
+
What loads the moment staff open the admin panel — this is the one area where Lunar ships more than expected.
+ +OrderStatsOverview widget — today vs. yesterday, last 7 vs. prior 7, last 30 vs. prior 30 days, both order count and sub-total, with up/down trend icons. Registered by default on Lunar's Dashboard page, and boboko's panel (3dealer/app/Providers/PanelServiceProvider.php) registers the stock panel with no pages()/Dashboard override — this ships as-is.OrdersSalesChart — ApexCharts area chart, monthly buckets over the trailing year, dual y-axis (order count / sub-total). Same "no override" reasoning as above applies to every widget on this page.AverageOrderValueChart — one series per CustomerGroup plus a synthetic guest series, monthly average of sub_total over the trailing year.NewVsReturningCustomersChart reads Order::new_customer, a real boolean column set by Lunar\Jobs\Orders\MarkAsNewCustomer (true when no prior order existed for that customer at placement time) — not a cosmetic flag.LatestOrdersTable — last 10 placed orders, 60s polling, reuses OrderResource's own table columns.Which products are actually selling, and what's about to run out.
+ +PopularProductsTable — groups lunar_order_lines by product identifier over the trailing 12 months, ranked by quantity sold, with revenue (sub_total) alongside. Physical products only (whereType('physical')).statsproduct module was researched as the comparison point (per-product page-view + sales detail) — boboko has no page-view capture at all (see 04), so even the sales half of this can't be built without the traffic half.statscatalog module researched as the reference. No equivalent surface in Lunar or boboko-core — would be a straightforward aggregate over lunar_products/lunar_collections, just not built.ProductVariant::$stock is a plain point-in-time integer column — no stock-movement ledger or history table exists in lunarphp/core (grepped the models and migrations directories). Turnover reporting needs a time series of stock levels or receipts/sales deltas; today's schema only has "current stock," so there's nothing to compute turnover from yet, not just a missing report.ProductIndexer's in_stock field exists for search/listing purposes — nothing aggregates it into a "low stock" admin view or report.Value and behavior at the level of one shopper, or a group of them.
+ +CustomerStatsOverviewWidget on the customer view page — total orders, average spend, and total spend, computed live from orders()->sum()/average(). This is per-customer lookup, not an aggregate report across all customers.What happens before an order exists — the storefront side neither repo instruments at all.
+ +gtag/dataLayer/GA4/any client-side event tracker — zero hits. No storefront event of any kind is dispatched, captured, or stored anywhere.Order) — no view or add-to-cart events exist to build the earlier steps from, consistent with the Cart survey's finding that Lunar dispatches zero cart events.CartResource, already shipped) — this is a rolled-up metric: total abandoned value this week, abandonment rate as a percentage of carts started. The underlying rows exist in lunar_carts/lunar_cart_lines (same query CartResource's Abandoned tab already runs), but nothing aggregates them into a rate or a trend — it's list-only today.utm_source/medium/campaign at first touch, tied forward to the eventual order.Getting numbers out of boboko and into someone else's books.
+ +lunar_orders migration stores both tax_breakdown (JSON, per-rate detail) and tax_total as real columns on every placed order — this is genuine underlying data, not inferred.tax_breakdown/tax_total across orders into a filing-ready report by TaxZone or period — no such widget, page, or query exists in Lunar or boboko-core.Exporter/ExportAction/Excel:: across lunarphp/lunar and boboko-core's src — no hits. Filament ships export actions as a first-party feature elsewhere in the ecosystem; nothing here wires one up for orders.Order::channel_id is a real, always-populated foreign key (verified in the lunar_orders migration) — every order already knows its channel. No report groups by it; the dashboard's charts are all channel-blind.A distinction worth being explicit about, since it's easy to mistake one for the other.
+ +docs/lunar.md's Activity Logging section: Lunar\Base\Traits\LogsActivity covers Order, Cart, Product, Customer, and 15 other models, recording only dirty attributes per change under the lunar log name.
+ A feature-by-feature pass across Shopify, WooCommerce, and PrestaShop's checkout
+ layer — sourced, not recalled from memory — checked against what
+ Lunar's Cart::createOrder() / order-creation pipeline
+ actually supports today. Companion to the Cart survey: this starts where that one
+ left off — address and shipping-option capture through to a placed order. For
+ deciding what to design next, not a build order.
+
Who's allowed to check out, and in how many steps.
+ +Order.user_id and customer_id are both nullable, and ValidateCartForOrderCreation never checks for either — it only requires a billing address and, if shippable, a shipping address + option. A cart with no user_id creates an order fine.setShippingAddress()/setBillingAddress()/setShippingOption() as independent calls that a UI can sequence however it likes. WooCommerce and PrestaShop both ship one-page as a plugin/theme layer, not core, so this isn't a Lunar gap so much as storefront work still to do.setShippingAddress() call.Payments facade is driver-based (Payments::driver('card')) so a wallet driver is architecturally pluggable, but none ships, and there's no one-tap "skip the address form" path since address capture still runs through the standard cart-address flow first.Order.meta and Cart.meta are both free-form JSON columns carried straight through FillOrderFromCart ('meta' => $cart->meta) — technically able to record a timestamp/version of accepted terms today, but no dedicated field, checkbox validation, or admin display exists.What actually happens inside createOrder(), verified from source.
Cart::draftOrder() matches on fingerprint() + total, so re-running createOrder() on an unchanged cart reuses the same draft order instead of duplicating it (CreateOrder::execute()); once an order is placed, hasCompletedOrders() throws DisallowMultipleCartOrdersException unless allowMultipleOrders is explicitly passed.Order::isDraft()/isPlaced() gate on placed_at; orders.draft_status config (default awaiting-payment) sets the initial status. The order exists — and can be re-run through the pipeline idempotently via the fingerprint match above — before a payment driver ever authorizes anything.orders.pipelines.creation chain does this explicitly — FillOrderFromCart, CreateOrderLines, CreateOrderAddresses, CreateShippingLine, CleanUpOrderLines, MapDiscountBreakdown — each copying cart state into immutable order rows rather than referencing the cart live.ValidateCartForOrderCreation requires country_id, first_name, line_one, city, postcode on billing always, and on shipping too unless the chosen ShippingOption->collect is true (in-store pickup skips a shipping address).FillOrderFromCart copies currency_code and exchange_rate from the cart's currency onto the order at creation — later currency-config changes don't retroactively alter placed orders.What tells the customer (and staff) an order happened.
+ +config/lunar/orders.php carries a mailers and notifications array, but grep across core turns up exactly one reader of that config (Order::getStatusLabelAttribute(), and it only reads label). Nothing in core ever dispatches a mailer or notification from a status change — those keys are unwired placeholders, not a working feature.src/Events/ in core contains only PaymentAttemptEvent. No OrderCreated, no OrderStatusUpdated. Confirmation email, staff Slack ping, or customer SMS on status change all have to be built from scratch on plain Eloquent model events (Order::updated()), same pattern as the cart-event gap.Order.reference, status, OrderAddress.contact_email) but no lookup mechanism — a guest with no account has no route back to their order without the confirmation email that also doesn't exist yet.CreateOrder::execute() dispatches MarkAsNewCustomer::dispatch($order->id) as a queued job after every order creation — genuinely wired, unlike the mail/notification config above.Distinct from abandoned cart recovery (covered in the Cart survey) — this is someone who reached address/email capture and still left.
+ +Order::isDraft() plus the address already captured on it — but per the Cart survey's finding, there's no Filament resource for Cart and (unverified here, likely the same gap) no dedicated "abandoned checkout" view distinguishing a draft order with a captured address from one that never got that far.OrderAddress.contact_email, where an abandoned guest cart usually has none — but nothing sends on it.What the customer sees the moment money is on screen.
+ +TaxZone.price_display is a first-class enum (tax_inclusive/tax_exclusive), and Price::priceExTax()/priceIncTax() both exist on the model — more complete than PrestaShop, where dual-price display is a separately-sold addon module, not core.Cart.taxBreakdown and OrderLine.tax_breakdown are both populated structured objects (iterate .amounts), not just a lump-sum total — the data supports a itemized tax display, a storefront just has to render it.Currency.exchange_rate plus sync_prices per non-default currency, and the rate is snapshotted onto the order at creation (see 02) — the same mechanics PrestaShop needs an addon for.attribute_data + Language, but checkout itself — form labels, validation errors, status labels — is storefront-owned Laravel localization, not something Lunar's order pipeline touches either way.ShippingOption.collect is a real boolean the validator checks directly — when true, ValidateCartForOrderCreation skips the shipping-address requirement entirely. Modeled at the same level as the collection driver in the Table Rate Shipping add-on.
+ A feature-by-feature pass across Shopify, WooCommerce, and PrestaShop's
+ account layer — sourced, not recalled from memory — checked against what
+ Lunar's Customer/Address/CustomerGroup
+ models actually support today and what exists (or doesn't) in boboko-core
+ and 3dealer right now. For deciding what to design next, not a build order.
+
The storefront-facing account experience, as distinct from staff/admin auth in Modules\Core\Auth.
Customer::users() / User::customers() via customer_user pivot (LunarUser trait), plus User::latestCustomer().Modules\Core\Customer\Listeners\CreateCustomerForUser attaches a new Customer to every User on UserCreated, gated by config('core.auto_create_customer_for_user').composer.json, no login/register views, nothing in routes/web.php. Only Modules\Core\Auth's Filament staff panel login exists.AccountController, no account/profile route, no matching Blade views anywhere in 3dealer's app/ or resources/views — confirmed by exhaustive grep.resources/views/components/header.blade.php has a cart icon and a search button but no account/login link at all — not even a dead one. The cart icon itself links to /cart, which also has no matching route, matching this codebase's known stubbed-UI pattern.Letting a customer see and follow their own orders without contacting support.
+ +Customer::orders() and User::orders() both exist (Lunar\Models\Order), with status, line items, addresses, and transactions already relational.Order for a logged-in customer — the data exists, nothing surfaces it. Shopify's rebuilt (2026) customer-accounts UI and PrestaShop's order-detail tracking page are both native; WooCommerce ships this in My Account by default.Order/OrderLine/shipping models in vendor/lunarphp/core; PrestaShop's tracking module patches this same gap with a third-party add-on, so it isn't a "native everywhere" bar either.Cart::add() already supports the mechanics, nothing wires an Order line back into a new cart.What a returning customer doesn't have to retype.
+ +Customer::addresses() (HasMany) — Lunar\Models\Address has no cap on count.Address::shipping_default and billing_default booleans; AddressObserver auto-unsets the previous default when a new one is flagged, so only one of each can be true at a time.AddressRelationManager (src/Customer/RelationManagers/AddressRelationManager.php) touches addresses today — that's an admin back-office view, not a storefront one. No customer-facing CRUD exists.lunarphp/core or boboko-core wires a geocoding/validation service — this is a storefront-only concern layered on top of the plain line_one…postcode fields.How a customer gets in, and how forgiving that path is.
+ +Modules\Core\Auth\Services\OtpService/UserOtpService exist but are wired to staff/Filament login, not a customer-facing flow.UserOtpService and UserOtpMail already implement an OTP-by-email mechanism for the staff panel — the building block for a customer-facing passwordless flow exists, just not exposed to a storefront route. Shopify ships this as sign-in links (6-digit email code) by default in its 2026 customer accounts.laravel/socialite in either composer.json. Shopify offers Google/Facebook sign-in and "Sign in with Shop" natively; this would be a from-scratch integration here.Cart::user_id/customer_id nullable-until-claimed design would support it once a checkout and account UI exist.Whether a returning customer can skip re-entering card details.
+ +lunarphp/core or boboko-core's payment integration. Even Shopify gates this behind Enterprise; WooCommerce's version depends entirely on gateway-level tokenization (e.g. Stripe), not a core feature.Keeping track of products outside the cart.
+ +wishlist model, table, or reference anywhere in src/ or vendor/lunarphp — grep confirms zero hits. Shopify also has no native wishlist (third-party apps like Flits fill the gap); WooCommerce/PrestaShop are the same story via plugins, so this is a genuinely common gap, not a boboko-specific one.Where boboko is already ahead of a typical single-tenant storefront — Lunar's CustomerGroup does real work here.
CustomerGroup model plus HasCustomerGroups trait — Product::customerGroup() scope and Price's polymorphic customer-group awareness are both real, shipped behavior, not scaffolding.HasCustomerGroups::scheduleCustomerGroup() / unscheduleCustomerGroup(), backed by CanScheduleAvailability — supports a starts_at/ends_at window per group, e.g. early access for wholesale.Customer::users()->sync([...]) already supports attaching several Users to one Customer record — the data model allows a shared company account today, but nothing (invite flow, role/permission split between company users, storefront switch-account UI) is built on top of it. PrestaShop's "Multi-User Customer Account" add-on is the closest native comparison, and it's a paid third-party module there too.HasCustomerGroups::bootHasCustomerGroups() auto-syncs default groups on creation), but nothing lets a customer request/select a group like "wholesale" at signup — that's currently a staff-only Filament action via CustomerResourceExtension.Longer-tail account features — noted for completeness, not depth (data rights specifically overlaps a separate Privacy survey).
+ +lunarphp/core or boboko-core — Discount's BuyXGetY type is the closest primitive, but it's a promo mechanic, not an accruing balance. PrestaShop and WooCommerce both rely on third-party modules for this too (Knowband, Webkul, Yith).boboko:anonymize — a local-environment-only dev command that scrubs users/lunar_customers for testing, not a customer-facing GDPR flow. WooCommerce's closest native equivalent is also a paid add-on (Data Privacy Manager); flagged briefly here, full treatment belongs to the separate Privacy survey.lunarphp/core. This is WooCommerce Subscriptions/Shopify-app territory on the platforms researched too — not a core-package feature anywhere.boboko-core · competitive spec sheet
+A feature-by-feature audit of Lunar's Discount engine against promotion tooling in Shopify, WooCommerce, and PrestaShop. Each row is graded against the underlying Lunar source, not the docs.
The two shipped discount types and the machinery that decides whether they fire.
+ +Built in as Lunar\DiscountTypes\AmountOff. applyPercentage() and applyFixedValue() distribute the discount across eligible lines, tracking per-currency fixed values (data.fixed_values.{code}) so the amount is currency-aware, not a single converted number.
Built in as Lunar\DiscountTypes\BuyXGetY. Condition lines and reward lines are configured separately via discountableConditions/discountableRewards; getRewardQuantity() computes how many reward units a given condition quantity earns, with an optional max_reward_qty cap.
checkDiscountConditions() compares strtoupper($cart->coupon_code) against $discount->coupon; Discounts::validateCoupon() exposes a standalone check. Coupon is cast via CouponString on the model.
A blank coupon column makes a discount apply to every eligible cart with no code entered — DiscountManager::getDiscounts() queries whereNull('coupon')->orWhere('coupon', '') when the cart carries no coupon code.
checkDiscountConditions() reads data.min_prices.{currency} and compares it against $lines->sum('subTotal.value'). Configurable per-currency in the admin form's "Minimum cart amount" fieldset — but only enforced by AmountOff, see row below.
AmountOff::getEligibleLines() filters/rejects cart lines against discountableLimitations/discountableExclusions plus collections()/brands() pivot rows typed limitation or exclusion. Configured through five separate Filament relation managers on the discount record.
Whether a discount is currently live, and how hard its usage caps are enforced.
+ +Discount::getStatusAttribute() derives active/pending/expired/scheduled from starts_at/ends_at; the Filament table badges this status column directly (green/gray/red/blue via DiscountResource::getTableColumns()).
Discount::scopeUsable() filters query-side (uses < max_uses OR max_uses IS NULL) before a discount is even fetched; checkDiscountConditions() re-checks it in AmountOff. markAsUsed() increments uses and attaches the user via discount_user.
checkDiscountConditions() calls usesByUser() only when $cart->user exists — a guest checkout cannot be capped per-customer since there's no user_id to key against, only customer_id. Wholesale/B2B carts often complete without a Laravel User attached, so the cap silently no-ops for them.
BuyXGetY::apply() never calls checkDiscountConditions() — grep the method body, it's absent. A coupon-gated, min-spend-gated, or max-uses-capped BOGO discount ignores all three conditions; only the min-quantity/reward math runs. AmountOff::apply() calls it correctly by contrast.
What happens when more than one discount could legally apply to the same cart.
+ +stop field is dead code. It's a real column, cast as boolean on the model, and it's a live toggle in the Filament admin form (DiscountResource::getStopFormComponent()) — but a repo-wide grep of both lunarphp/core and lunarphp/lunar for reads of $discount->stop outside the model and the form turns up nothing. DiscountManager::apply() is a plain unconditional foreach over every fetched discount; nothing ever breaks the loop. Staff can toggle a setting that has zero runtime effect.
+ DiscountManager::getDiscounts() ends with orderBy('priority', 'desc')->orderBy('id'), and the admin form exposes low/medium/high (1/5/10) presets. This genuinely controls apply order.
See callout above — stop is unread at runtime. Every active, eligible discount is applied every time; there is no way to make one discount exclusive of the rest short of writing a custom AbstractDiscountType that inspects $cart->discounts itself.
Shopify models discounts as Product/Order/Shipping classes with an explicit "Combines with" toggle per pair. Lunar has no discount class concept at all — AmountOff and BuyXGetY are the only two types and neither declares a class or combination policy.
$cart->discountBreakdown (a collection of DiscountBreakdown value objects, one per applied discount with its affected lines) gives a storefront the raw data to render "2 promotions applied," but no UI ships to render it — it's a data structure a storefront app must build its own component against.
Both AmountOff::applyFixedValue() and applyPercentage() explicitly skip a line when $line->discountTotal->value > $amount — "if this line already has a greater discount value, don't add this one as they already have a better deal." This is a real per-line max-discount guard, just not a whole-cart exclusivity rule.
"Buy more, save more" mechanics — and the separate pricing layer that actually implements some of them in Lunar.
+ +Discount. PricingManager::get() filters a purchasable's Price rows for min_quantity > 1 AND $this->qty >= $price->min_quantity and picks the cheapest matching price break. This is quantity-break pricing baked into the price table itself, resolved at Pricing::for($variant)->qty($n)->get() time — it never touches the Discount model, coupon system, or discount breakdown at all. A storefront gets the discounted unit price with no visible "discount applied" line.
+ Via the Price model's min_quantity/pricing pipeline described above, not Discount. Configured directly on product variant pricing in the admin, no separate promotion object needed.
AmountOff takes one flat percentage or fixed value per discount record; there is no multi-tier threshold structure in data. Reaching this today means creating several separate Discount rows, each with its own min_prices floor, and hoping only the intended one wins (compounded by the stop gap in section 03).
No bundle or kit concept anywhere in lunarphp/core's catalog or discount models. Shopify/WooCommerce/PrestaShop all support this via dedicated bundle apps or plugins layered on the same primitive Lunar lacks — a discount keyed to a co-purchased product set rather than any single line.
BuyXGetY's automatically_add_rewards flag drives processAutomaticRewards(), which inserts a brand-new CartLine for a randomly selected reward product and zeroes its price via discountTotal. $cart->freeItems tracks which purchasables were added this way.
Lunar has two genuinely different mechanisms here that solve overlapping-looking problems — conflating them is the easiest mistake to make.
+ +CustomerGroup pricing and Discount customer-group scoping are not the same feature. Pricing::for($variant)->customerGroups($groups)->get() resolves a different base price per customer group directly from the Price table (wholesale sees $8, retail sees $10 — two rows, no discount object, no coupon, nothing to "apply"). Discount::customerGroups() is a separate pivot (customer_group_discount, via the HasCustomerGroups trait) that scopes whether a promotion is visible/enabled to a group at all, with its own starts_at/ends_at/enabled/visible per-pivot-row scheduling. One is differential pricing; the other is promotion eligibility. Both exist and both work, but they're wired into completely separate code paths.
+ PricingManager::get(): $potentialGroupPrice filters Price rows with a matching customer_group_id and picks the cheapest; falls back to $basePrice when no group price exists.
DiscountManager::getDiscounts() applies ->customerGroup($this->customerGroups) via the shared HasCustomerGroups trait's scopeCustomerGroup(), configured on the discount's own "Availability" sub-page (ManageDiscountAvailability) alongside channel restriction.
Discount::customers() pivot (customer_discount), checked in checkDiscountConditions(): if the discount has any tied customers, a cart without a matching customer_id fails eligibility outright. Managed via CustomerLimitationRelationManager in the admin.
Lunar does compute an order-level new_customer boolean (Jobs\Orders\MarkAsNewCustomer, ! $previousOrder) — but it's a post-order reporting flag surfaced only in the Filament order table/dashboard chart. Nothing reads it during ApplyDiscounts; there's no "is this customer's first order" condition available to a Discount at checkout time.
No referral concept anywhere in lunarphp/core or lunarphp/lunar — not a model, job, or config key. Common as a bolt-on in WooCommerce/Shopify via loyalty apps (e.g. WPLoyalty's referral-points module); would need to be built from scratch on top of Discount::customers() at best.
No points ledger, balance, or redemption model exists in Lunar core. A loyalty program (points-to-discount conversion, VIP-tier multipliers) is a third-party plugin layer in every researched competitor, not core commerce logic — same gap here, but Lunar offers no AbstractDiscountType hook obviously suited to "redeem N points" either, since discount eligibility has no notion of a spendable balance.
What it takes to reach a feature Lunar doesn't ship, without forking the package.
+ +Discounts::addType(MyType::class) appends to DiscountManager::$types (seeded with just AmountOff::class, BuyXGetY::class). A new type extends AbstractDiscountType and implements apply(CartContract $cart) — the same contract the two built-ins use, so it participates in the same unconditional-foreach loop from section 03.
Requires additionally implementing Lunar\Admin\Base\LunarPanelDiscountInterface (lunarPanelSchema()/lunarPanelOnFill()/lunarPanelOnSave()) for DiscountResource::getDefaultForm() to render a config section for it. The interface exists and is wired in, but there is no shipped example implementation to copy from beyond AmountOff/BuyXGetY, which are hard-coded into the form rather than using the interface themselves.
boboko-core · competitive gap survey · 03
+
+ Lunar's payment layer (Lunar\Facades\Payments, Transaction, the offline
+ driver) is wired for a single "pay on delivery / bank transfer" flow. Everything downstream of
+ that — cards, wallets, saved methods, self-service refunds, retries — is either scaffolded in
+ Lunar core and unused here, or absent from the stack entirely. This is a research survey, not a
+ build plan.
+
boboko currently ships one payment type: cash-in-hand via the offline driver. Every card/wallet/BNPL path below is theoretically pluggable but has zero live implementation.
+config/lunar/payments.php (3dealer's published copy): 'cash-in-hand' => ['driver' => 'offline', 'authorized' => 'payment-offline'], backed by Lunar\PaymentTypes\OfflinePayment.lunarphp/stripe is not present in either boboko-core/vendor/lunarphp or 3dealer/vendor/lunarphp, and not listed in either composer.json. docs/lunar.md's Stripe section documents Lunar's general capability, not something wired into this project.Lunar\Managers\PaymentManager extends Laravel's Manager; Payments::extend('custom', fn ($app) => ...) registers a new driver, and any class extending Lunar\PaymentTypes\AbstractPayment implementing authorize()/capture()/refund() plugs in. The scaffolding is solid — nothing beyond offline is plugged into it yet.The core primitives (intent/capture/refund, partial amounts, transaction chaining) exist in Lunar and are exposed in the Filament admin — but nothing calls them outside cash-in-hand, and none of it is customer-facing.
+Lunar\Base\PaymentTypeInterface defines authorize(), capture(Transaction $t, $amount), refund(Transaction $t, int $amount, $notes); Transaction::capture()/refund() forward to the transaction's own driver() via Payments::driver($this->driver).OfflinePayment::capture() just returns new PaymentCapture(true) unconditionally — there's no real deferred-capture gateway wired up to exercise the distinction.$data['amount'] to $transaction->capture(bcmul($data['amount'], $record->currency->factor)) in ManageOrder.php — the plumbing supports partial amounts, but only staff can trigger it, and only against a real (non-offline) driver would it mean anything.$response = $transaction->refund(bcmul($data['amount'], ...), $data['notes']), and isPartiallyRefunded() / order status logic (partial-refund, refunded) compares refundTotal against captureTotal/intentTotal. This genuinely works today through the offline driver's no-op refund().Transaction.parent_transaction_id chains captures to intents and refunds to captures, and nothing in the model stops multiple transaction rows per order — but no code path in this repo actually retries a failed attempt with a second transaction; it's schema support, not a driven flow.Lunar\Observers\TransactionObserver::created() logs every transaction (amount, type, status, card_type, last_four, reference, notes) via Spatie activity log automatically — this is real and unconditional, independent of driver.stripe/webhook route, but that package isn't installed here, so there is no webhook endpoint of any kind in this project today.Lunar\Events\PaymentAttemptEvent is dispatched from OfflinePayment::authorize() with the resulting PaymentAuthorize DTO — a real, listenable event, though only one driver currently fires it.Everything a shopper would touch directly — saved cards, one-click repeat purchase, self-service refunds — is absent. Lunar's payment layer is staff/checkout-oriented, not account-oriented.
+Lunar\Models — no PaymentMethod/Card model, no field on Customer. 2026 trend research (Nuvei, Checkout.com) treats network-tokenized saved cards as baseline for one-click checkout.ManageOrder.php (Actions\Action::make('refund')), gated behind admin auth. WooCommerce/PrestaShop ecosystems commonly expose a customer-initiated return/refund request flow; nothing equivalent exists here.PaymentTypeInterface or the offline driver. getPaymentChecks() exists as an extension point (Lunar\Base\DataTransferObjects\PaymentChecks, an iterable of pass/fail PaymentCheck DTOs) but AbstractPayment::getPaymentChecks() just returns an empty collection — real fraud tooling (Stripe Radar-style) isn't behind it.Transaction::paymentChecks() → driver's getPaymentChecks($transaction) is real, typed infrastructure for surfacing checks (e.g. "AVS matched") in the admin UI — but the default implementation is a no-op, so nothing populates it today.Lunar's multi-currency model covers pricing display, not multi-currency payment settlement; recurring billing/dunning has no representation at all.
+Lunar\Models\Currency (code, exchange_rate, decimal_places, default) with sync_prices-gated conversion, documented in docs/lunar.md "Channels and Currencies" — this is genuinely wired, cart/pricing layer already uses it.Lunar\Models or boboko-core. This is a one-time-purchase order/cart model end to end.
+ A feature-by-feature pass across GDPR/CCPA compliance tooling used by Shopify,
+ WooCommerce, and dedicated consent-management platforms — sourced, not recalled
+ from memory — checked against master and the substantial,
+ unmerged Privacy branch ("Feature: Creating Privacy
+ Basics") already built in this repo. For deciding what to finish and merge
+ next, not a build order.
+
Privacy
+ branch (53 files, +3127/‑24 across two commits: 9f540cb,
+ 59303cf) but not on master — treated as partial, not
+ have, until it merges. boboko:anonymize is the one privacy-adjacent
+ command that already lives on master today.
+ GDPR Art. 15 (access) and Art. 17 (erasure) — the two rights every DSAR tool is built around.
+ +Privacy branch only: PrivacyService::requestExportForCustomer()/requestExportForUser() queue ExportDataSubjectJob, which gathers every registered provider's data and writes a CSV-per-provider zip via WriteExportToCsvListener. Not on master.Privacy branch only: PrivacyService::requestErasureForCustomer()/requestErasureForUser(), extensible via config('core.privacy.providers') — the same config-array-registration pattern as NotificationRegistry, keyed off Modules\Core\Privacy\Contracts\PersonalDataProvider.Privacy branch only: 30-day default (core.privacy.grace_period_days), reverted automatically on login via CancelErasureOnLoginListener — same pattern Shopify's own account-deletion flow uses. No native platform documents this as a first-party primitive; it's usually left to a third-party app.Privacy branch only: requestImmediateErasureForCustomer()/ForUser(), typed to accept only Staff $requestedBy so a self-service path cannot reach it even by accident.Privacy branch only, and a genuinely uncommon feature: PrivacyService splits every operation into Customer-scope vs. User-scope, plus a sole-owner cascade (CascadeCustomerErasureListener) when erasing the last linked User orphans a Customer. No researched competitor product handles B2B multi-seat erasure this explicitly.master: src/Command/AnonymizeCommand.php (boboko:anonymize) — scrubs users/lunar_customers, environment-guarded to local only. Distinct from GDPR erasure; the Privacy branch README diff explicitly flags this is not the compliance tool.Deletion isn't the only lawful outcome — these are three different operations, often confused with each other.
+ +Privacy branch only: OrderDataProvider::eraseForCustomer() clears PII fields but keeps order rows/totals/tax data intact, citing GDPR Art. 17(3)(b)'s legal-obligation exception — reports ErasureOutcome::Pseudonymized, not Erased, distinctly.Privacy branch only: PersonalDataProvider deliberately has no central taxonomy — each provider (CustomerDataProvider, AddressDataProvider, OrderDataProvider, CartDataProvider, ReviewDataProvider) decides erase vs. pseudonymize vs. skip for its own table. docs/privacy.md flags ReviewDataProvider's scope choice as needing review before relying on it.Privacy branch is triggered by an explicit request, not a retention-policy timer (e.g. "delete guest carts after 2 years," "purge OTP logs after 90 days").Privacy branch only: DataErasureRequest.report stores the full per-provider outcome as a snapshot (not a live lookup), specifically so the audit record stays readable after the underlying data is gone.What a visitor is asked before tracking starts, and whether that choice is recorded anywhere.
+ +Customer Privacy API recognizing four consent signals (analytics, marketing, preferences, sale-of-data); WooCommerce relies entirely on third-party plugins for this.Modules\Core — no consent flag found on the Customer/User models on either branch.Privacy branch's audit trail covers erasure/export requests only, not consent events.Terms of service and privacy policy as tracked, versioned documents — not just static pages.
+ +Modules\Core. Researched as a standard requirement for surviving a legal dispute or regulatory inquiry.Whether cardholder data ever actually reaches boboko's own infrastructure.
+ +docs/lunar.md "Stripe integration" — payment flows through Lunar's Stripe driver (Lunar\Stripe\Facades\Stripe, fetchOrCreateIntent()/PaymentIntents), so PAN never lands in a boboko/Lunar database. Researched: this pattern alone can cut PCI-DSS scope by roughly 90% per industry sources.docs/ documents or asserts SAQ-A eligibility for a consuming app's own compliance paperwork.Beyond GDPR — the other regimes a storefront selling outside the EU may need.
+ +What happens when something goes wrong, or when a third party is handling data on the shop's behalf.
+ +docs/lunar.md, but nothing formally tracks or discloses it as a subprocessor.
+ A feature-by-feature pass across Shopify, WooCommerce, PrestaShop, and general
+ 2026 storefront UX trends — checked against what
+ Modules\Core\Catalog actually ships in boboko-core
+ and what 3dealer's storefront actually calls. Unlike the rest of this survey
+ series, this concern is not a blank slate: a real Meilisearch-backed catalog
+ layer (listing, filtering, facets, search, collections, a product-option-type
+ system) was built this session. The gaps here are mostly about storefront wiring
+ and discovery/merchandising UX, not backend plumbing.
+
ProductService::facets(),
+ priceRange(), list()'s sort param) already exist and
+ work; nothing in CategoryController passes them through yet.
+ The Meilisearch-backed layer everything else in this survey sits on top of — this is where most of this session's real build lives.
+ +ProductService::list() reads Product::search('') via Meilisearch and returns a real LengthAwarePaginator — used end-to-end by CategoryController::show() and rendered by x-product-grid.ProductFilters::collectionId matches ProductIndexer's collection_ids field, which unions a product's direct collections with all ancestors — so a parent-category page picks up products attached only to a leaf subcategory. Wired in CategoryController.ProductFilters supports brand, minPrice/maxPrice, inStockOnly, fully implemented in ProductService::buildFilter() — but category/show.blade.php's price slider and in-stock checkbox are hardcoded markup with no form submission; CategoryController never constructs a ProductFilters with any of these three.ProductService::facets() returns value→count via Meilisearch facetDistribution, correctly scoped to co-applied filters — but nothing storefront-side calls it. No brand/attribute facet list renders anywhere in category/show.blade.php.ProductService::priceRange() reads Meilisearch facetStats for a correct, filter-scoped min/max — the sidebar instead shows a static "€10 - €50" label with a non-functional apply button.ProductSort enum + ProductIndexer::getSortableFields() (price, created_at) work end-to-end in ProductService::list(sort: ...) — the storefront's sort <select> is explicitly commented {{-- Dummy — not wired to real sorting yet --}} and includes a "popularity" option with no backing signal at all.ProductSearchService::search() is a complete, locale-aware, fallback-safe implementation (attributesToSearchOn targeting current + default locale) — but no search route exists in 3dealer (routes/web.php only has product.show/category.show), and both the header search icon and the sidebar search box are inert buttons/inputs.ProductService::getById()/getBySlug(), both zero-database-read lookups against the slugs/id filterable fields. ProductController::show() uses getById() directly.Category tree browsing, breadcrumbs, and merchandising — what turns a flat product list into a navigable store.
+ +CollectionService::list() with CollectionFilters(rootOnly/parentId/groupId), backed by CollectionIndexer's nested-set parent_id/_lft fields — no database read needed to build a nav tree.components/header.blade.php renders a CSS-only hover dropdown from a $categories list passed into the layout, linking to category.show.CollectionIndexer indexes a full root-first ancestors array ({id, name}) specifically so a breadcrumb needs zero extra queries — but category/show.blade.php and product/show.blade.php both build a flat two-level x-breadcrumb (Home → this category/product) by hand, never reading ancestors. A product under a three-deep category shows no intermediate levels.category/show.blade.php renders only the collection name/description above a plain product grid — no banner image field, no "featured in this category" pinning above organic results. CollectionIndexer's thumbnail field exists but isn't read on the category page at all (only used, if anywhere, for nav-level imagery).ProductIndexer beyond brand and in_stock — a category page can't offer "filter dresses by size" the way Shopify/WooCommerce faceted nav does; would need new filterable fields on custom product attributes plus sidebar UI.CollectionIndexer computes product_count (including descendant collections) at index time by querying the product index directly — correct and cheap, but nothing in category/show.blade.php or the nav dropdown displays it.What a shopper sees once they land on a single product — media, variants, reviews, cross-sell.
+ +product/show.blade.php's product-gallery Stimulus controller — thumbnail rail, main image, full popover lightbox with prev/next/counter — fed from ProductIndexer's full media array (not just a single thumbnail).ColorOptionType lets an admin attach a hex code to an option value → ProductIndexer::mapVariant() embeds meta.hex per variant → x-ui.color-swatch renders real swatch buttons wired to a product-form Stimulus controller that swaps price/image on selection.ProductOptionTypeInterface system is explicitly built to be extensible — a PatternOptionType or MaterialOptionType is a new class plus a Filament form, no core change needed — but only ColorOptionType is registered, and x-ui.color-swatch itself hardcodes a background-color swatch, not a generic swatch renderer.ProductReview model, fully indexed (items/count/average_rating, PII-safe), live-reindexed on review create/update/delete via ReviewServiceProvider, and rendered in product/show.blade.php's Reviews tab with x-review-card/x-review-form.application/ld+json or itemscope markup anywhere in 3dealer's views. Rich results (price/rating/availability in Google Shopping) are a significant organic-CTR lever per 2026 SEO guidance — the product page already has every field (price, rating, stock) a Product schema block would need, just not emitted.Lunar\Base\Enums\ProductAssociation::CROSS_SELL/UP_SELL/ALTERNATE, $product->associate()/associations() — see docs/lunar.md "Products and Variants") but nothing in Modules\Core\Catalog surfaces it, and the "Σχετικά προϊόντα" block at the bottom of product/show.blade.php is four fully hardcoded fake products with href => '#'.ProductIndexer's document and no badge markup on x-ui.product-card — would need either a computed signal (e.g. "new" from created_at, "sale" from compare_price already indexed per-variant) or an admin-set tag, neither wired to a visual badge today.Product/ProductType and no UI for it on the product page. Not especially relevant to 3dealer's current catalog (3D-printed goods), but a real gap for any apparel-leaning store built on this core.in_stock is indexed and known per-product (ProductIndexer::toSearchableArray()), but there's no subscription model, email trigger, or UI for a shopper to ask to be notified — the signal exists, nothing acts on it.ProductReview) exists, which is a distinct concept (post-purchase rating, not pre-purchase Q&A).2026 trend-adjacent features, mostly backed on other platforms by paid apps/plugins rather than core — useful for calibrating how unusual these gaps are.
+ +x-ui.product-card links straight to product.show with a hover-revealed "add to cart" button only — no modal/preview interaction. Current UX research flags quick-view modals as a common INP (responsiveness) failure point, so the absence isn't purely a gap to close blindly.category/show.blade.php uses classic x-ui.pagination against the real paginator from ProductService::list() — works correctly, just page-based rather than scroll-based. Research is genuinely mixed on whether infinite scroll is even preferable for conversion/SEO, so this is a parity note, not a clear gap.Lunar\Models\Product/ProductVariant or Modules\Core\Catalog.ProductIndexer's media array is just Spatie media-library images (url/thumb) — no video or 360° asset type modeled, and no AR integration. The gallery component (product-gallery Stimulus controller) is generic enough to extend to a video slide without a rewrite, but nothing does today.HasUrls/Url model supports per-locale slugs per product (indexed in ProductIndexer's slugs field), but there's no per-variant URL — selecting a color swatch changes displayed price/image via product-form client-side state, not the URL, so a specific variant can't be linked or indexed separately.
+ A feature-by-feature pass across Shopify, WooCommerce, and PrestaShop's shipping
+ layer — sourced, not recalled from memory — checked against what
+ Lunar core's ShippingManifest and the
+ lunarphp/table-rate-shipping add-on actually support
+ today, and what's actually wired up in boboko-core and 3dealer right now.
+ For deciding what to design next, not a build order.
+
The mechanism Lunar core provides for offering and applying a shipping charge — everything else in this survey is built on top of it.
+ +Lunar\Base\ShippingModifier abstract class + ShippingManifest::addOption() — any package can register options onto the manifest via a pipeline of modifiers (ShippingModifiers::getModifiers()).Lunar\Pipelines\Cart\ApplyShipping — reads ShippingManifest::getShippingOption($cart) or a manual shippingOptionOverride, writes a ShippingBreakdown and shippingSubTotal onto the cart before CalculateTax runs.Cart::isShippable() — true if any line's purchasable (e.g. ProductVariant::isShippable()) is shippable; a digital-only cart skips the shipping-address requirement entirely.Cart::setShippingOption() → SetShippingOption action, validated by ShippingOptionValidator, triggers a recalculate. Nothing in 3dealer's storefront calls it yet — no shipping step exists in the UI.Lunar\Pipelines\Order\Creation\CreateShippingLine writes an immutable shipping-type order line from the cart's shipping breakdown at checkout — survives later rate changes.lunarphp/table-rate-shipping is installed (composer.json, pinned ^1.3) and its ShippingPlugin is registered in CorePlugin::boot() — so 3dealer inherits it automatically, it doesn't need its own registration.
ShippingZone model, type unrestricted|countries|states|postcodes; ShippingZoneResolver::get() matches a cart's address against zone scope, falling back to any unrestricted zone.Drivers\ShippingMethods\FlatRate::resolve() — one price per cart subtotal via Pricing::for($shippingRate).Drivers\ShippingMethods\ShipBy::resolve() — data['charge_by'] is cart_total or weight, tiered via priceBreaks, with customer-group price overrides taking priority.Drivers\ShippingMethods\FreeShipping::resolve() — data['minimum_spend'] (per-currency array supported), optional use_discount_amount to check against post-discount subtotal.Drivers\ShippingMethods\Collection::resolve() — zero-price option, flagged collect: true on the ShippingOption. Single implicit "store" — no concept of which location, no per-location stock or hours.ShippingExclusionList + ShippingZone::shippingExclusions() — every driver checks it before resolving and returns null if any cart line's product is excluded from that zone.ShippingMethod::customerGroups() pivot carries visible, enabled, starts_at, ends_at — scheduling and audience-gating a rate is already modeled.ShippingZoneResource, ShippingMethodResource, ShippingExclusionListResource ship with the add-on — usable as soon as the Filament plugin is registered, which it is via CorePlugin.resources/views beyond a passing mention in components/footer.blade.php — the whole backend above is unwired to any customer-facing UI.Real carriers quoting and printing on Lunar's behalf, rather than merchant-defined flat/tiered rates.
+ +table-rate-shipping calls an external carrier API — all four shipped drivers (FlatRate, ShipBy, FreeShipping, Collection) compute from local data. Shopify's CarrierService API is the model for this: shop sends weight/dims/destination, carrier returns live rates at checkout.ProductVariant has weight_value/weight_unit (referenced in ShipBy's weight tier and docs/lunar.md) but no length/width/height fields exist in core migrations — enough for weight-tier rating, not enough for carrier-grade dimensional/volumetric quotes.OrderShippingZone pivot table records which zone an order matched, but no field anywhere stores a carrier tracking number or shipment status.Where an order physically ships from, and whether it can ship from more than one place.
+ +vendor/lunarphp/core or lunar — stock is a flat quantity on the variant. WooCommerce needs Calcurates or WooCommerce Warehouses add-ons for this; it's genuinely not a Lunar concept at all.CreateShippingLine writes exactly one shipping line per order.Collection driver models pickup as a single yes/no rate per zone — no location entity to pick from, no per-location hours/capacity.ShipBy/FlatRate (carrier-agnostic priced shipping) and Collection (pickup) exist as concepts.ShippingOption, CartAddress, or the order shipping line. WooCommerce needs a dedicated delivery-date-picker plugin for this too — not a gap unique to Lunar, but still open here.What happens when a shipment crosses a border, or something goes wrong in transit.
+ +Product/ProductVariant migrations. Every international shipment needs one per line item to clear customs — researched requirement, not yet modeled anywhere in Lunar.CalculateTax pipeline step handles sales tax/VAT on the cart itself, not import duty estimation for cross-border orders. DDP vs. DDU is the standard framing (seller-collects-upfront vs. customer-pays-on-delivery) — neither is modeled.ShippingZone type countries/states/postcodes already scopes which rates apply where — the building block international shipping would sit on top of.